commit 03084e2d5ae0c4bbcde83a0626f12abf8ad0848b Author: Charles Escobar Date: Sun Aug 23 00:57:40 2026 -0500 Commit inicial: G Radio Player Sistema de automatización de radio en Rust (GTK4 + GStreamer): reproductor principal con crossfade y ducking, scheduler de comerciales, refill de playlist, y herramientas auxiliares (pautaje, parrilla, botonera, visor, buscador, playlist, grabador, reportes). Incluye sistema de skins y modos de Players (paneles de reproducción configurables). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0290eee --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Rust build artifacts +/target/ +/target-bookworm/ +**/*.rs.bk +Cargo.lock.bak + +# Debian packaging staging +gradio-player_*_amd64/ +gradio-player_*_arm64/ +gradio-player_*_arm64_bookworm/ + +# Runtime logs +nohup.out +*.log + +# Instaladores generados (releases publicados en el servidor de descargas) +*.deb +GR-player-RC1-*.zip +sha256sum.txt + +# Parches sueltos de edición (no son fuente) +*.css_patch + +# Editor / OS junk +.DS_Store +*.swp +*.swo +*~ +.vscode/ +.idea/ +.claude/ + +# Notas internas que no deben subirse +chat_*.txt +arm64-fix.txt +streaming.txt +v*-packaging.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b6cb41 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,278 @@ +# Changelog — G Radio Player + +Todas las versiones publicadas de `gradio-player` (paquete .deb). + +--- + +## [0.4.17] — 2026-08-22 + +### Nuevo + +- **Sistema de skins**: la apariencia de la app (iconos de botones, colores CSS y fondo de ventana) ahora se puede reemplazar sin recompilar, mediante carpetas en `~/.gradio/data/skins//` (`iconos/.png`, `fondo.png`/`.jpg`, `estilo.css`). Aplica a las 9 herramientas (`radio-player`, `gr-pautaje`, `gr-parrilla`, `gr-botonera`, `gr-visor`, `gr-playlist`, `gr-record`, `gr-buscador`, `gr-reportes`). El skin activo se elige desde Configuración → Skin (apariencia) en `radio-player` y se aplica la próxima vez que se abre cada ventana. Se incluye `assets/skins/ejemplo/LEEME.txt` documentando el formato para crear skins propios. +- **Players (paneles de reproducción visibles)**: nuevo ajuste en Configuración con tres modos — 3 (Deck A + Deck B + barra de comercial, el layout de siempre), 2 (un solo panel de deck fusionado + barra de comercial) y 1 (un solo panel que muestra el deck activo o el comercial en curso, lo que esté sonando). El panel fusionado sigue siempre al tema que acaba de arrancar: durante un crossfade, el entrante toma el control apenas empieza a sonar y el saliente deja de reflejarse en pantalla aunque siga en fadeout. Requiere reiniciar `radio-player` para aplicarse, igual que el selector de Skin/Idioma. + +--- + +## [0.4.16] — 2026-08-10 + +### Correcciones + +- **El relay podía quedar registrado como "zombi" y el cliente remoto (Android/gr-client) dejaba de poder conectar hasta reiniciar `radio-player` a mano**: en `relay.rs`, el sink WebSocket hacia el relay era un único `Arc>` compartido por tres tareas (ping periódico, respuesta a los pings del relay, forwarder TCP→WS local) y ningún `.send()` tenía timeout. Si la conexión TCP se volvía silenciosamente inservible (p.ej. la IP pública del router cambia o una entrada de NAT expira, sin RST/FIN) un solo envío colgado retenía el mutex para siempre; el relay dejaba de recibir Pong y desregistraba el ID a los 5 minutos **sin cerrar el socket**, y `radio-player` nunca se enteraba (la conexión TCP local seguía viéndose `ESTAB`) — quedaba conectado en apariencia pero invisible para el relay hasta un reinicio manual. Ahora toda la escritura pasa por una única tarea con `SEND_TIMEOUT=15s`: si un envío se cuelga, se detecta y se fuerza una reconexión completa (re-registro) automáticamente. + +--- + +## [0.4.15] — 2026-07-25 + +### Nuevo + +- **Protocolo remoto ampliado para gr-client/Android**: `AgregarAlPlaylist` acepta ahora `al_inicio` (bool, default `false` — compatible con clientes anteriores) para insertar un resultado de búsqueda al inicio de la lista además de al final, igual que los botones ▲▼ de `gr_buscador.rs` en el vivo. +- **"Próximas tandas" de comerciales vía remoto**: nuevos mensajes `ObtenerProximasTandas` / `ReproducirTanda`, que reutilizan el mismo escaneo de horario (`scan_upcoming_breaks`) y la misma señal IPC (`cmd_play_tanda`) que usa el panel "Próximas tandas" del vivo — el cliente remoto ahora ve la tanda corriente y las próximas 4, con botón ▶ para adelantar cualquiera. + +### Correcciones + +- **Búsqueda remota (gr-client/Android) devolvía resultados duplicados, algunos "audio inválido o corrupto"**: `buscar_audio()` en `servidor.rs` recorría **todo** el `$HOME` cuando el cliente no especifica carpeta base (el caso normal desde Android), a diferencia del buscador de escritorio (`gr_buscador.rs`), que ya acotaba la búsqueda a las carpetas reales de la parrilla. Eso hacía que cada tema apareciera repetido una vez por cada backup viejo (`Jazler RS Backups`, `.gradio-2024-*`, etc.) y por cada versión empaquetada de `radio-player` guardada en el home (cada una con su propia copia de `Time/`), y que rutas obsoletas de esos backups/paquetes viejos se mostraran como resultado aunque el archivo ya no existiera ahí. Ahora `buscar_audio()` usa el mismo criterio que el vivo: acota a las carpetas derivadas de la parrilla (`~/.gradio/data/parrilla/*/*.mus`), filtra rutas del índice `locate` que ya no existen en disco, y deduplica por ruta. + +--- + +## [0.4.13] — 2026-07-25 + +### Correcciones + +- **La conexión remota vía Internet (relay) no funcionaba nunca**: `relay.rs` toma la URL del servidor relay de la variable de entorno `GRADIO_RELAY_URL`; ni `run.sh` ni el lanzador `gradio.sh` generado por `package-deb.sh` la definían, así que caía siempre al placeholder `wss://relay.example.com/ws` (inexistente). El módulo reintentaba conectar cada 30s en silencio, sin error visible en la UI, y `radio-player` nunca llegaba a registrarse en `relay.gradio.net` — por lo que ningún cliente remoto (gr-client, Android) podía encontrarlo aunque "relay habilitado" estuviera activo en Configuración. Ahora ambos lanzadores exportan `GRADIO_RELAY_URL=wss://relay.gradio.net/ws` por defecto (override posible seteando la variable antes de lanzar). + +--- + +## [0.4.12] — 2026-07-24 + +### Correcciones + +- **Los tooltips no se mostraban nunca, en ningún botón**: el timer de UI (200ms) llamaba `set_tooltip_text()` en cada ciclo sobre el título del deck — `None` en el deck inactivo, o el mismo path repetido en el deck activo — incluso sin cambio de valor. GTK4 reinicia el temporizador de hover de tooltips para **toda la ventana** cada vez que se llama `set_tooltip_text()` en cualquier widget, sin importar si el valor cambió. Como siempre hay al menos un deck sin pipeline (o uno reproduciendo) en todo momento, la llamada se repetía sin parar y el tooltip nunca alcanzaba a mostrarse en ningún botón de la app. Ahora se recuerda el último valor aplicado y solo se llama `set_tooltip_text()` cuando realmente cambia. +- **Ícono incorrecto en el panel/taskbar bajo sesiones Wayland** (p. ej. XFCE-Wayland en Debian 13/Trixie): GTK4 no soporta fijar el ícono de ventana por píxeles en Wayland (`gdk_toplevel_set_icon_list` es no-op); el escritorio debe resolverlo emparejando el `app_id` de la ventana con un archivo `.desktop` instalado. El `.desktop` se llamaba `gradio-player.desktop`, sin coincidir con el `application_id` real (`com.gradio.radio-player`), lo que impedía ese match y podía dejar un ícono cacheado incorrecto (de otra app) en el panel. Renombrado a `com.gradio.radio-player.desktop` en todos los instaladores (.deb, install.sh, install-arch.sh). + +### Mejoras + +- **Tooltip con ruta completa también en el nombre del tema en reproducción** (barra superior), igual que ya mostraba el título dentro de cada deck. +- **Botón IA reubicado**: pasa de estar como primer elemento junto al label de volumen (se veía suelto) a quedar agrupado con el resto de íconos de herramientas en el grupo derecho. + +--- + +## [0.4.11] — 2026-07-14 + +### Correcciones + +- **"No fundir" cortaba el final del audio**: el disparo por posición que adelanta el cambio de tema (`(dur - pos) <= crossfade_secs`) se ejecutaba igual aunque la carpeta estuviera marcada como excluida de crossfade. Al llegar a ese punto, el arranque directo apagaba el pipeline saliente de inmediato (`shutdown_pipeline`), cortando los últimos `crossfade_secs` segundos del audio (típicamente locuciones de IA) en vez de dejarlo terminar. Ahora, si `fundido_omitido` determina que el tramo saliente→entrante no debe fundirse, el adelanto por posición se omite y se espera al EOS natural del pipeline (igual que con loop activado). +- **Autoplay no arrancaba aunque hubiera parrilla configurada**: el chequeo de "hay tracks para reproducir al iniciar" se hacía una única vez (con delay cero en Linux) justo tras `window.present()`. Si en ese instante `playlist-refill` (proceso aparte) todavía no había escrito el primer `playlist4`, o el timer de sincronización de la UI (200ms) aún no lo había recargado a `st.playlist`, el autoplay se perdía para siempre — no había reintento. Ahora se sondea cada 200ms hasta 20 segundos (más el margen de 2s existente en Windows para el registro de plugins GST) antes de darse por vencido. + +--- + +## [0.4.9] — 2026-06-27 + +### Nuevo + +- **Asistente de Inteligencia Artificial integrado**: nueva opción "Inteligencia Artificial" en el diálogo de configuración. Al habilitarla (con aceptación explícita de condiciones), aparece el botón "🤖 IA" a la izquierda del indicador de volumen en la barra de herramientas. +- **Botón IA**: lanza `opencode-terminal` con contexto completo de G-Radio (`GRADIO_OPENCODE_DIR=~/.gradio/data/opencode`). Si `opencode-terminal` no está instalado, muestra un aviso con instrucciones. Fallback a `x-terminal-emulator`/`xterm` si `opencode-terminal` no se encuentra. +- **Contexto IA en assets**: carpeta `assets/opencode/` con `AGENTS.md`, agente `agents/g-radio.md` y seis archivos de contexto en `context/` (estructura, programación, comerciales, eventos, eventos-espera, comandos IPC). El instalador los copia a `~/.gradio/data/opencode/`. +- **Campo `ia_habilitada`** en `GradioConfig` (línea 18 de `gradio.config`). Compatibilidad retroactiva: instalaciones anteriores sin línea 18 arrancan con IA deshabilitada. +- **`opencode-terminal` v0.2.0** (binario companion independiente): terminal GTK4 + VTE4 que lanza opencode con contexto G-Radio, verifica actualizaciones en background y ofrece `opencode upgrade` si hay nueva versión disponible. + +--- + +## [0.4.8] — 2026-06-23 + +### Correcciones + +- **`prerm` rompía dpkg al desinstalar/actualizar** (regresión presente en todos los `.deb` de 0.3.x a 0.4.8): el script usaba `pkill -f "radio-player"`, y como `"radio-player"` es subcadena de `"gradio-player"`, el match por línea de comando completa (`-f`) mataba al propio `gradio-player.prerm` y al proceso `dpkg`/`apt`, dejando el gestor de paquetes en estado `half-configured`. Corregido usando `pkill -x` (match exacto por nombre de proceso). Para reparar una máquina ya afectada, ver `REPARAR-DPKG.md`. **Nota:** los `.deb` arm64/arm64_bookworm 0.4.8 deben recompilarse en la RPi para incluir este fix. +- **Hora sin ducking de música**: al reproducir la hora sobre la música, ambas sonaban al mismo nivel. Ahora `play_hora` baja el deck musical activo al 80% del volumen principal (upvol) mientras suena la hora al 100%, y lo restaura al terminar. +- **Eventos/comerciales no cargaban streams de audio**: el scheduler (`comercial-scheduler`) descartaba silenciosamente las URLs de streaming (http/https) porque `is_playable_cached` intentaba leer metadatos del filesystem, que falla para URLs. Ahora se detectan URLs y tokens especiales ("Hora") y se pasan directamente sin validación de archivo local. Aplica a comerciales, eventos y eventos en espera. +- **Eventos emergentes sin reconexión de stream**: los streams en `play_eventos_emergentes` usaban código inline sin lógica de reconexión. Reemplazado por `play_comm_audio_top` que incluye 10 reintentos con 2 segundos entre reconexiones y detección de caída por bus de error. +- **Eventos en espera no se tomaban**: misma causa raíz que el punto anterior — las URLs en archivos `.com` de eventos-espera eran descartadas por el scheduler y nunca llegaban a `eventos-esperalist`. +- **Streams sin duración se colgaban**: si un stream no declaraba duración (campo vacío en el `.com`), `play_comm_audio_top` y `play_comm_audio` esperaban un EOS que nunca llega en streaming en vivo. Ahora se aplica un default de 60 segundos cuando no se declara duración, con warning en el log. La duración declarada (ej. `https://stream.example.com 900`) se respeta sin cambios. + +--- + +## [0.4.6] — 2026-05-22 + +### Cambios + +- **Licencia**: el proyecto se publica bajo **GPL-3.0-or-later** (archivo `LICENSE`). +- **Sanitización para publicación abierta**: + - URL de streaming pre-cargada removida del diálogo "Agregar URL"; ahora se muestra solo un placeholder genérico (`https://stream.example.com/mi-radio`). + - `RELAY_URL` ya no está hardcodeado; se toma de la variable de entorno `GRADIO_RELAY_URL` (default: `wss://relay.example.com/ws`). Para autohospedaje del relay, definir esta variable antes de lanzar. + - `build-rpi3.sh`: la IP del host remoto se toma de la variable `RPI3_HOST` o del segundo argumento (`./build-rpi3.sh --push user@host`). + - Etiquetas i18n y manual: se removieron referencias al dominio propietario. +- Cabeceras `SPDX-License-Identifier: GPL-3.0-or-later` en `src/main.rs` y `src/lib.rs`. +- `Cargo.toml`: campo `license = "GPL-3.0-or-later"` y `readme = "README.md"`. + +--- + +## [0.4.5] — 2026-05-20 + +### Novedades + +- **i18n Fase 2 — cobertura final de la UI**: traducidos los strings visibles de la UI de las ventanas secundarias, los binarios externos y el editor de pautaje al stack es/en/pt: + - `ui/dialogo_url.rs`: diálogo "Agregar URL de Streaming" completo (placeholder URL, historial, duración 0=continuo, botones). + - `ui/parrilla/ventana_parrilla.rs`: editor de parrilla musical (título, fuentes de audio, pisadores por hora, botones Leer/Grabar, frames Horas/Día, tooltips de drag&drop, nombres de los 7 días). + - `ui/visor/ventana_visor.rs`: visor de pautaje (encabezado, columnas Comercial/Tiempo/Días/Inicio/Fin, "HORA ACTUAL"/"HORA SIGUIENTE", "sin programación", sufijos "aleatorio"/"lista"). + - `ui/botonera/ventana_botonera.rs`: menú contextual y diálogo de selector de archivos (filtros incluidos). + - `ui/ventana_principal.rs` (GR Pautaje): toolbar completa (Subir/Bajar/Eliminar/Reproducir/URL/Hora/Línea/Limpiar), combos Tipo/Hora/Minuto, columnas Comercial/LMXJVSD/Inicio/Fin/Ruta, días Lunes…Domingo + "Todos", confirmación de eliminar caducados. + - `ui/panel_arbol.rs`: árbol Inicio/Sistema y entrada "Pautar carpeta completa (aleatoria)". + - `gr_buscador.rs`, `gr_reportes.rs`, `gr_playlist.rs`: ventanas auxiliares completas, incluyendo controles de pre-escucha, mensajes de estado, popovers de contexto y selectores de archivo. + - `main.rs` (radio-player): menú contextual de la cola de reproducción (CUE, Insertar Hora, Insertar Streaming, Cargar lista, Regenerar, Eliminar), ventana flotante de CUE (Pausa/Reanudar/Stop), encabezado del panel "🎵 Cola de reproducción", paneles "📢 Comerciales — próximas tandas" / "⏳ Eventos en espera" / "📅 Eventos", diálogos de Respaldo/Recuperación de GR, validación de Token obligatorio para el relay, diálogo "📡 Insertar Streaming" y de Ruta del archivo, confirmación de Interrumpir comerciales y nombre por defecto del preset. +- **Binarios auxiliares heredan el locale del config**: `gr-buscador`, `gr-reportes` y `gr-playlist` ahora leen la línea 17 de `~/.gradio/data/gradio_config` (campo `locale`) al arrancar, igual que el binario principal. Si está vacío caen al autodetect del entorno (LANG/LC_*). +- **Locale accesible desde la lib**: el módulo `i18n` se expone en `lib.rs` (`grpautaje::i18n`) para que los binarios externos puedan llamar `init()`/`tr()` sin duplicar las tablas. +- **Plantillas de strings con placeholders**: nuevas claves usan `{var}` reemplazado en el callsite con `tr(...).replace("{var}", ...)` para ítems con interpolación (status del buscador, conteos de reportes, mensajes de PDF/CSV, recuperación de GR, etc.). + +### Pendiente + +- Mensajes de log/error que viajan al cliente vía protocolo TCP — siguen como `Error { mensaje: String }` en español. Convertir a códigos enum traducibles por el cliente. + +--- + +## [0.4.4] — 2026-05-19 + +### Correcciones + +- **Cuelgue al cambiar idioma**: al seleccionar inglés/portugués y guardar, el diálogo de Configuración se cerraba en el mismo turno que se abría el aviso "Reiniciar para aplicar". GTK4 quedaba con un grab modal huérfano (el aviso era transient del padre que ya estaba destruyéndose) y la app dejaba de aceptar foco — en algunos compositores arrastraba al WM entero. Reescrito `show_info_dialog` para encadenar un callback `on_close`; el diálogo padre ahora se cierra recién cuando el usuario descarta el aviso. + +### Novedades + +- **i18n Fase 1.5 — ampliación de cobertura**: además de los ~25 strings de la Fase 1, se traducen los tooltips de los 15 botones de la barra de herramientas (Iniciar, Stop General, Siguiente, Hora, Pisador, Duck, Buscador, Pautaje, Parrilla, Playlist, Botonera, Configuración, Visor, Grabar, Reportes), los títulos de Deck A/B, la etiqueta "Sin comercial", el panel "⬛ Cola activa" / "🗑 Vaciar" / "Sin tandas programadas…", la barra de volumen "Vol / Duck / Fundido" y prácticamente todas las etiquetas del diálogo de Configuración GR (tarjetas de audio, nombre del medio, fundido, pisador, carpetas nacionales/intercultural/excluidas, silencio, volúmenes, puerto y token gr-client, relay, ID de relay, no-repetición). Sigue pendiente la Fase 2: paneles secundarios (Pautaje, Parrilla, Botonera, Visor, Reportes) y mensajes de log. + +--- + +## [0.4.3] — 2026-05-19 + +### Novedades + +- **Multidiomas (es / en / pt) — Fase 1**: la interfaz acepta el idioma del sistema operativo (`LC_ALL` / `LC_MESSAGES` / `LANG`) y soporta español, inglés y portugués. Nuevo selector "Idioma:" en el diálogo de Configuración con opción "Automático". Al cambiarlo, se persiste como línea 17 de `~/.gradio/data/tmp/gradio.config` y se pide reiniciar para aplicar. +- Strings traducidos en esta fase: títulos de ventanas (G Radio Player, GR Procesador DSP), diálogos comunes (Guardar/Cancelar/OK/Cerrar/Interrumpir/Generar), tooltips de configuración (volumen up/down, puerto cliente, token, DSP, VU meter, vaciar cola), labels del "now playing" (Sin tema, Sin comercial, ⏹ Detenido al final del tema, Se detuvo: Detener después), diálogos de preset. + +### Pendiente (Fase 2) + +- Migrar el grueso de strings de la UI de `src/main.rs` y `src/ui/*.rs` (paneles de comerciales, parrilla, eventos, etc.) — quedan ~200 literales en español. +- Convertir el protocolo TCP `Error { mensaje: String }` a códigos enum traducibles por el cliente. + +--- + +## [0.4.2] — 2026-05-13 + +### Correcciones + +- **Plugin DSP — botón ON/OFF completamente rehecho**: el toggle anterior dependía de `try_lock()` sobre el mutex del DSP global, que podía fallar silenciosamente si un hilo de audio lo tenía tomado en ese instante. El resultado era que el estado real del DSP no cambiaba aunque la UI mostrara el cambio. Se reemplazó por un `AtomicBool` global (`PROC_ENABLED`) que es la fuente autoritativa: el botón hace `store()` atómico sin ningún mutex, y los pad probes lo leen directo en cada buffer (sin sincronización por versión). + +- **Plugin DSP — efecto inmediato al encender desde mid-song**: al encender el procesador con una canción en curso, el efecto no tomaba hasta el siguiente tema porque `process_block()` chequeaba `self.cfg.enabled` que era `false` (valor con que se construyó el pipeline cuando el DSP estaba apagado) y devolvía sin procesar. El probe ahora fuerza `dsp.cfg.enabled = true` antes de llamar `process_block()` cuando `PROC_ENABLED` es verdadero. + +- **Plugin DSP — congelado al apagar**: apagar el procesador podía congelar brevemente la UI porque el handler del botón hacía un `lock().unwrap()` bloqueante sobre el mutex global mientras el hilo de audio podía tenerlo tomado. Cambiado a `try_lock()` para la actualización de `AppState.dsp`; la escritura en disco es best-effort. El procesamiento real se controla exclusivamente por `PROC_ENABLED`. + +- **Plugin DSP — ventana de config no revierte el estado ON/OFF**: al mover sliders en la ventana de configuración con el procesador apagado (o viceversa), `apply()` propagaba el valor `cfg.enabled` obsoleto de `cfg_arc` (capturado al abrir la ventana) al DSP global, reactivando o desactivando el procesador en contra del estado del botón. Ahora `apply()` sincroniza `cfg.enabled = PROC_ENABLED.load()` antes de guardar y propagar, de modo que la ventana de config nunca interfiere con el toggle. + +- **Plugin DSP — pipeline siempre en stand-by**: `build_pipeline()` ahora crea siempre el probe DSP independientemente de si el procesador está activo al momento de construir el pipeline. Cuando `PROC_ENABLED` es falso, el probe retorna inmediatamente sin copiar ni modificar el buffer (fast path sin contención). Esto permite encender/apagar en tiempo real sin reconstruir los pipelines. + +- **Tracks cortos cortados por crossfade**: si un audio en la playlist tenía una duración menor o igual al tiempo de fundido configurado, el trigger de crossfade por posición se disparaba de inmediato (la condición `dur - pos ≤ crossfade_secs` era verdadera desde el primer tick) y el track se cortaba en los primeros segundos. Agregada la guardia `dur > crossfade_secs` en los detectores de Deck A y Deck B; tracks más cortos que la ventana de crossfade llegan naturalmente al EOS. + +--- + +## [0.4.1] — 2026-05-11 + +### Correcciones + +- **Plugin DSP — sin crujido al inicio de cada audio**: cada pipeline crea su propio `DspProcessor` con estado limpio (filtros biquad del crossover en cero). En v0.4.0 todos los pipelines compartían el mismo objeto y los registros de retardo del tema anterior contaminaban los primeros frames del nuevo. El compresor ahora arranca con `gain_db = makeup_db` en lugar de 0, eliminando el pop inicial mientras el compresor converge. +- **Plugin DSP — VU metros activos en la ventana de configuración**: los metros ahora leen de variables atómicas globales (`DISP_LEVELS`, `DISP_GRS`) escritas directamente por el pad probe. En v0.4.0 la UI leía del master `DspProcessor` que ya no procesaba audio, por eso las barras aparecían apagadas. +- **Plugin DSP — ajustes de parámetros en tiempo real**: el pad probe verifica un contador de versión atómico (`PROC_CFG_VER`) en cada buffer; si cambió, recarga la config del master y llama `update_config()` sin bloquear el hilo de audio. En v0.4.0 los cambios de knobs no llegaban al pipeline activo. +- **Tandas de comerciales — botones ▲▼✕ funcionan correctamente**: el índice visual de cada ítem no coincidía con la línea real del archivo `.com` cuando había ítems filtrados por día/fecha. Ahora cada ítem guarda su `file_line` real y las operaciones de swap/delete lo usan. Además los botones aplican el cambio y reconstruyen el panel inmediatamente. +- **Panel "⬛ Cola activa" en la sección de comerciales**: encima de las próximas 4 tandas aparece la cola actual (`comercialeslist4`) con fondo rojo oscuro, mostrando lo que sonará al terminar el tema en curso. Incluye botón ✕ por ítem para borrado individual y botón 🗑 Vaciar todo. Se refresca en < 200ms cuando el scheduler carga comerciales al segundo :58 o cuando el operador modifica la cola. + +--- + +## [0.4.0] — 2026-05-10 + +### Novedades + +#### Plugin de procesamiento de audio DSP multiband + +- **Botón 🎚 Procesador** en la barra de herramientas: activa o desactiva el procesador de audio en vivo. El ícono cambia entre `processor-off` y `processor-on` según el estado. El estado (activo/inactivo) persiste entre reinicios. +- **Botón ⚙ Config DSP** (junto al anterior): abre la ventana de configuración completa del procesador con la interfaz rack de GR-Processor. +- **Cadena DSP**: crossover Linkwitz-Riley de 4.° orden → compresor linked-stereo RMS feed-forward por banda → clipper suave (tanh) → limitador brick-wall. Soporta de 2 a 6 bandas configurables. +- **Ajuste en tiempo real**: todos los pipelines activos (música, comerciales, eventos) comparten el mismo `Arc>`; cualquier cambio de parámetro se refleja inmediatamente en el audio en reproducción sin necesidad de reiniciar o esperar la próxima canción. +- **Ventana de configuración**: knobs Cairo idénticos al GR-Processor autónomo (THR, RATIO, ATK, REL, GAIN, CLIP). Sliders de ganancia de entrada/salida y umbral del limitador en paneles laterales. +- **Presets**: 6 presets de fábrica (ROCK, BLUE, CLASIC, VOICE, HARD BASS, STRONG). Botones **▶ LOAD** y **● SAVE** para cargar y guardar presets propios en `~/.config/gradio/presets/`. El último preset usado se recuerda al reiniciar. +- **Configuración persistente**: `~/.gradio/data/processor.json` guarda estado (on/off), todos los parámetros de bandas, ganancias y último preset. + +#### Panel de comerciales — Próximas 4 tandas + +- El panel "📢 Comerciales" reemplaza la lista plana de la cola activa por una vista de **las próximas 4 tandas programadas**, leyendo directamente los archivos `.com` del horario. +- Cada tarjeta muestra: hora de reproducción, duración acumulada de la tanda y botón **▶ Play**. +- **Botón ▶ Play** en cada tanda: hace fadeout del tema en curso, reproduce la tanda inmediatamente y luego continúa con el playlist. Marca la tanda como ya reproducida para que el scheduler no la vuelva a cargar. +- Los botones **▲ ▼ ✕** de cada ítem editan directamente el archivo `.com` del horario (no la cola activa). +- Cada tarjeta tiene un fondo de color diferente (azul / verde / ámbar / violeta) para diferenciar visualmente los bloques. +- El panel se refresca automáticamente cada 30 segundos. + +#### Otras mejoras + +- **Nombres de comerciales legibles**: se decodifica el percent-encoding del URI de GStreamer al mostrar el título en la barra de estado (`LAMEGAAVANCE%20KALIMAN` → `LAMEGAAVANCE KALIMAN`). +- **Íconos del procesador**: tres nuevos PNG en `assets/` — `processor-off.png`, `processor-on.png`, `processor-config.png`. + +--- + +## [0.3.9] — 2026-05-05 + +### Correcciones +- **Drop & drop al área extendida del playlist**: el `playlist_vbox` no se + estiraba con el frame, dejando muerta la zona inferior cuando se ampliaba + la ventana. Se forzó `vexpand+Fill` y se agregó un `DropTarget` catch-all + en `lists_row` con ruteo por X. +- **Tooltips de los botones ⬆/⬇/🗑 de la cola**: se borraban cada 200 ms + porque el rebuild del listbox se disparaba con cada escritura de + `playlist-refill`, aunque el contenido visible no cambiara. El trigger + ahora compara un signature de paths+duración. + +### Novedades +- **Validación de audio antes de encolar** (vía `symphonia`): cualquier + archivo cuyo decode falle se descarta automáticamente. Aplica a: + `playlist-refill`, `comercial-scheduler` (3 schedulers), drag-drop a la + cola y a comerciales, botones ⤒/⤓ y doble-click del buscador, y los + comandos remotos TCP `AgregarAlPlaylist` / `ReproducirAhora` / + `PlaylistPlayAhora`. Resuelve el cuelgue del player ante audios corruptos. + +--- + +## [0.3.8] — 2026-05-04 + +### Correcciones +- **Drag & drop buscador → playlist**: el drop solo funcionaba sobre la mitad superior del panel; los `DropTarget` ahora cubren `playlist_listbox`, `playlist_scroll` y `playlist_frame` para todo el área visible (incluyendo cuando la ventana se extiende tras abrir el buscador). +- **Reanudar tras pausa larga**: si se pausaba un tema y se reanudaba minutos después, el detector de drift wall-vs-pos disparaba un crossfade espurio. `action_play` ahora realinea `current_track_start` con la posición real del pipeline al salir de Paused. +- **Editor `gr-playlist`**: aceptaba solo `gio::File`; ahora también STRING (text/uri-list desde gr-buscador) y `FileList`, instalados sobre listbox y scroll. + +### Novedades +- **Buscador**: cada fila de resultado lleva dos botones: ⤒ insertar al inicio del playlist y ⤓ agregar al final (íconos `up.png` / `down.png`). +- **Comerciales / Eventos / Eventos en espera**: cada fila muestra duración (con cache para no relanzar `ffprobe`) y botones inline ↑/↓ que reordenan las líneas en el archivo correspondiente. + +--- + +## [0.3.6] — 2026-05-01 + +### Novedades +- Ícono del pisador reemplazado por `sello.png`. +- Panel Playlist: nueva cabecera con botón **Automático** (robot.png) que pausa/reanuda `playlist-refill`. Verde oscuro = automático activo; rojo oscuro = pausado. +- Panel Playlist: botón **Vaciar** (trash.png) para vaciar la cola de reproducción de inmediato. +- `playlist-refill`: respeta el archivo `tmp/pause_refill`; cuando existe, el daemon duerme sin rellenar la cola. + +--- + +## [0.2.0] — 2026-04-01 + +### Novedades +- (pendiente: describir cambios respecto a 0.1.0) + +### Correcciones +- + +--- + +## [0.1.0] — versión inicial + +- Primera versión pública del sistema de automatización de radio. +- Radio-player GTK4 con crossfading y duck de volumen. +- Daemons: `comercial-scheduler` y `playlist-refill`. +- Herramientas: `gr-pautaje`, `gr-parrilla`, `gr-botonera`, `gr-visor`, `gr-buscador`, `gr-playlist`, `gr-record`, `gr-reportes`. +- Instalador `.deb` para amd64. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..819eeb7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2745 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic_refcell" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gstreamer" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8757a87f3706560037a01a9f06a59fcc7bdb0864744dcf73546606e60c4316e1" +dependencies = [ + "cfg-if", + "futures-channel", + "futures-core", + "futures-util", + "glib", + "gstreamer-sys", + "itertools", + "libc", + "muldiv", + "num-integer", + "num-rational", + "once_cell", + "option-operations", + "paste", + "pin-project-lite", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gstreamer-app" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9a883eb21aebcf1289158225c05f7aea5da6ecf71fa7f0ff1ce4d25baf004e" +dependencies = [ + "futures-core", + "futures-sink", + "glib", + "gstreamer", + "gstreamer-app-sys", + "gstreamer-base", + "libc", +] + +[[package]] +name = "gstreamer-app-sys" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f7ef838306fe51852d503a14dc79ac42de005a59008a05098de3ecdaf05455" +dependencies = [ + "glib-sys", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-audio" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7ec7e0374298897e669db7c79544bc44df12011985e7dd5f38644edaf2caf4" +dependencies = [ + "cfg-if", + "glib", + "gstreamer", + "gstreamer-audio-sys", + "gstreamer-base", + "libc", + "once_cell", + "smallvec", +] + +[[package]] +name = "gstreamer-audio-sys" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5f3e09e7c04ec91d78c2a6ca78d50b574b9ed49fdf5e72f3693adca4306a87" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-base" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f19a74fd04ffdcb847dd322640f2cf520897129d00a7bcb92fd62a63f3e27404" +dependencies = [ + "atomic_refcell", + "cfg-if", + "glib", + "gstreamer", + "gstreamer-base-sys", + "libc", +] + +[[package]] +name = "gstreamer-base-sys" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f2fb0037b6d3c5b51f60dea11e667910f33be222308ca5a101450018a09840" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-pbutils" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf4bf5857fa22f910634e86a5bce33b5581a9e90caa4e32fd4a20bdd4c83ed0" +dependencies = [ + "glib", + "gstreamer", + "gstreamer-audio", + "gstreamer-pbutils-sys", + "gstreamer-video", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "gstreamer-pbutils-sys" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "304101f5fccbbe41e0169536777ddb7680c2c837e18575c22b30fc20cedfb76f" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-audio-sys", + "gstreamer-sys", + "gstreamer-video-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-player" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630c7c83566877365f2f5b2afa18d811ed816115deb8c2efc8c4351b685f451c" +dependencies = [ + "glib", + "gstreamer", + "gstreamer-player-sys", + "gstreamer-video", + "libc", +] + +[[package]] +name = "gstreamer-player-sys" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974fc9cf9c2768c84d42378799b654b56c3a6d787a8085969bd98f873dafd586" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-sys", + "gstreamer-video-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-sys" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feea73b4d92dbf9c24a203c9cd0bcc740d584f6b5960d5faf359febf288919b2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-video" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1318b599d77ca4f7702ecbdeac1672d6304cb16b7e5752fabb3ee8260449a666" +dependencies = [ + "cfg-if", + "futures-channel", + "glib", + "gstreamer", + "gstreamer-base", + "gstreamer-video-sys", + "libc", + "once_cell", + "thiserror 2.0.18", +] + +[[package]] +name = "gstreamer-video-sys" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a70f0947f12d253b9de9bc3fd92f981e4d025336c18389c7f08cdf388a99f5c" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muldiv" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "option-operations" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c26d27bb1aeab65138e4bf7666045169d1717febcc9ff870166be8348b223d0" +dependencies = [ + "paste", +] + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radio-player" +version = "0.4.17" +dependencies = [ + "anyhow", + "cairo-rs", + "chrono", + "dirs", + "env_logger", + "futures-util", + "gdk-pixbuf", + "gdk4", + "glib", + "glib-macros", + "gstreamer", + "gstreamer-app", + "gstreamer-audio", + "gstreamer-pbutils", + "gstreamer-player", + "gtk4", + "log", + "native-tls", + "rand", + "serde", + "serde_json", + "symphonia", + "tokio", + "tokio-tungstenite", + "zip", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c7a44bb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,101 @@ +[package] +name = "radio-player" +version = "0.4.17" +edition = "2021" +description = "G Radio Player — reproductor y herramientas de programación" +license = "GPL-3.0-or-later" +readme = "README.md" + +[lib] +name = "grpautaje" +path = "src/lib.rs" + +# ── Binario principal: interfaz GTK4 ────────────────────────────────────────── +[[bin]] +name = "radio-player" +path = "src/main.rs" + +# ── Binario auxiliar: programador de comerciales/eventos (segundo :58) ──────── +[[bin]] +name = "comercial-scheduler" +path = "src/comercial_scheduler.rs" + +# ── Binario auxiliar: relleno automático del playlist (cada 5 s) ───────────── +[[bin]] +name = "playlist-refill" +path = "src/playlist_refill.rs" + +# ── Buscador de audio ───────────────────────────────────────────────────────── +[[bin]] +name = "gr-buscador" +path = "src/gr_buscador.rs" + +# ── Editor de playlists .gradio ─────────────────────────────────────────────── +[[bin]] +name = "gr-playlist" +path = "src/gr_playlist.rs" + +# ── Grabador de audio ───────────────────────────────────────────────────────── +[[bin]] +name = "gr-record" +path = "src/gr_record.rs" + +# ── Módulos de gr-pautaje ───────────────────────────────────────────────────── +[[bin]] +name = "gr-pautaje" +path = "src/bin/gr-pautaje.rs" + +[[bin]] +name = "gr-parrilla" +path = "src/bin/gr-parrilla.rs" + +[[bin]] +name = "gr-botonera" +path = "src/bin/gr-botonera.rs" + +[[bin]] +name = "gr-visor" +path = "src/bin/gr-visor.rs" + +# ── Reportería de audios emitidos ──────────────────────────────────────────── +[[bin]] +name = "gr-reportes" +path = "src/gr_reportes.rs" + +[dependencies] +# GTK4 GUI +gtk4 = { version = "0.9", features = ["v4_6", "v4_8"] } +cairo-rs = { version = "0.20", features = ["png", "pdf"] } +glib = "0.20" +glib-macros = "0.20" + +# GStreamer +gstreamer = "0.23" +gstreamer-audio = "0.23" +gstreamer-player = "0.23" +gstreamer-pbutils = "0.23" +gstreamer-app = "0.23" +gdk4 = { version = "0.9", features = ["v4_6"] } +gdk-pixbuf = "0.20" + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Utilidades compartidas +rand = "0.8" +chrono = { version = "0.4", features = ["clock"] } +dirs = "5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +zip = "2" +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +futures-util = "0.3" +native-tls = "0.2" +log = "0.4" +env_logger = "0.11" + +# Validación de audio (decodificación pure-Rust) — usado por playlist-refill +# para descartar archivos corruptos antes de meterlos en la cola. +# Defaults: flac, mkv, ogg, pcm, vorbis, wav. Agregamos mp3, aac e isomp4. +symphonia = { version = "0.5", features = ["mp3", "aac", "isomp4"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 0000000..52fa3a4 --- /dev/null +++ b/MANUAL.md @@ -0,0 +1,575 @@ + +**G Radio Player v-0.61R — Manual de Usuario** + + + + Charles Escobar - 2026 + + +**Índice** +1. [Descripción general](#anchor-1 "#anchor-1") +2. [Requisitos del sistema](#anchor-2 "#anchor-2") +3. [Instalación y arranque](#anchor-3 "#anchor-3") +4. [Reproductor principal (radio-player)](#anchor-4 "#anchor-4") +- [Interfaz](#anchor-5 "#anchor-5") +- [Decks A y B](#anchor-6 "#anchor-6") +- [Controles globales](#anchor-7 "#anchor-7") +- [Lista de reproducción (playlist)](#anchor-8 "#anchor-8") +- [Reproducción de hora](#anchor-9 "#anchor-9") +- [Pisador automático](#anchor-10 "#anchor-10") +- [Comerciales y eventos](#anchor-11 "#anchor-11") +- [Preescucha CUE](#anchor-12 "#anchor-12") +- [Duck (bajar música)](#anchor-13 "#anchor-13") +- [Streaming de Internet](#anchor-14 "#anchor-14") +- [Configuración](#anchor-15 "#anchor-15") +5. [Parrilla musical (gr-parrilla)](#anchor-16 "#anchor-16") +- [Tipos de entrada · Selección aleatoria sin repetición · Carpeta "No Tocar"](#anchor-16 "#anchor-16") +- [Edición · Pisadores por hora](#anchor-16 "#anchor-16") +6. [Pautaje de comerciales (gr-pautaje)](#anchor-17 "#anchor-17") +7. [Botonera de efectos (gr-botonera)](#anchor-18 "#anchor-18") +8. [Visor de programación (gr-visor)](#anchor-19 "#anchor-19") +9. [Buscador de audio (gr-buscador)](#anchor-20 "#anchor-20") +10. [Reportería (gr-reportes)](#anchor-21 "#anchor-21") +11. [Editor de playlists (gr-playlist)](#anchor-22 "#anchor-22") +12. [Grabador de audio (gr-record)](#anchor-23 "#anchor-23") +13. [Estructura de archivos de datos](#anchor-24 "#anchor-24") +14. [Clientes remotos](#anchor-25 "#anchor-25") +- [Cliente de escritorio (radio-player-client)](#anchor-25 "#anchor-25") +- [Cliente Android (GRadio Client) + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OMQ2AUBBAsUfyNbBi9VRgEA3sWGAjJK2CbjNzVGcAAPzFtapV7V9PAAB47X4AEXIELdGZ+p4AAAAASUVORK5CYII=) +](#anchor-25 "#anchor-25") +**Descripción general** + + **G Radio Player** es un sistema de automatización radial escrito en Rust con interfaz GTK4 y motor de audio GStreamer. Gestiona la reproducción de música, comerciales, eventos, jingles de hora y efectos de sonido mediante tres procesos coordinados: + + | **Proceso** | **Función** | + + |-|-| + + | radio-player | Interfaz principal: reproduce música y comerciales, maneja crossfade y VU | + + | playlist-refill | Daemon: rellena la cola de música cada 5 segundos leyendo la parrilla del día | + + | comercial-scheduler | Daemon: carga los comerciales del minuto siguiente al :58 de cada minuto | + + + Los tres procesos se comunican a través de archivos en ~/.gradio/data/tmp/. + + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAM0lEQVR4nO3OMQ0AIAwAwZJgBKeVgjWMNCwYYCIkd9OP3zJzRMQMAAB+sfqJeroBAMCN2pTaBSQLg92+AAAAAElFTkSuQmCC) +**Requisitos del sistema** +- GTK4 ≥ 4.6 +- GStreamer ≥ 1.20 con plugins: base, good, bad, ugly, libav, ALSA, PulseAudio +- ffprobe (paquete ffmpeg) +- locate (mejora la velocidad del buscador) +- **gstreamer1.0-plugins-bad** — incluye `gstapp`, requerido por el plugin procesador DSP + + La instalación de dependencias es automática vía ./build.sh en sistemas Debian/Ubuntu. + +**Requisitos adicionales del plugin procesador DSP (opcional)** +El procesador multiband es una función opcional que se activa con el botón 🎚 de la barra de herramientas. Cuando está encendido, inserta una etapa DSP en todos los pipelines de audio (música, comerciales, eventos). No requiere hardware adicional ni tarjeta de sonido especial; usa el mismo dispositivo de salida del sistema. + + Si el paquete `gstreamer1.0-plugins-bad` no está instalado, el procesador no tendrá efecto aunque esté activado. Para instalarlo manualmente: + +`sudo apt install gstreamer1.0-plugins-bad` + + En Raspberry Pi 5 (ARM64), el procesador con 5 bandas consume menos del 5% de CPU. En equipos x86-64 el impacto es imperceptible. + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OMQ2AABAAsSNhYEMBJlD4Mz7xgQU2QtIq6DIzR3UFAMBf3Gu1VefXEwAAXtsfSqQDW2Qf4EYAAAAASUVORK5CYII=) +**Instalación y arranque** +# Compilar e instalar dependencias del sistema + ./build.sh + + # Iniciar el sistema completo (radio-player + daemons + watchdog) + ./run.sh + + # Iniciar solo el reproductor (para pruebas) + ./target/release/radio-player + +El script run.sh lanza los tres procesos y un watchdog que los reinicia automáticamente si se detienen. + + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OQQmAABRAsScYxpg/kR2sYQKvNrCCNxG2BFtmZquOAAD4i3Ot7mr/egIAwGvXA4DuBdDaS4drAAAAAElFTkSuQmCC) +**Reproductor principal (radio-player)** +**Interfaz** + + + + La ventana principal se divide en: +- **Cabecera**: logo, nombre de la emisora y versión +- **VU Meters**: barras L/R que muestran el nivel de audio en tiempo real +- **Deck A** y **Deck B**: paneles de reproducción con título, posición, duración y barra de progreso +- **Barra de comercial**: muestra el comercial o evento en curso +- **Barra de controles globales**: botones de acceso a todas las herramientas +- **Lista de reproducción**: cola de temas pendientes +**Decks A y B** +G Radio usa dos decks alternados para lograr el **crossfade** (fundido cruzado) entre temas: +- **Deck A** — temas impares de la secuencia +- **Deck B** — temas pares de la secuencia + + Cada deck tiene sus propios controles: + + | **Botón** | **Función** | + + |-|-| + + | ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADsAAAA8CAYAAADYIMILAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAF/klEQVR4nO2aXY+aWhSGH0AU8WPUpqZTvWpS73rTi/n5/QH9A02d+IWC38gooAjIuWjgOJ3pnFNHrdP2TYgJOzHrYe291tprb+H9+/cRf4jEX23AOfUX9ndV6qnBRqNBvV6nVCqdy55nybIsBoMBzWbz0XHhsQCVz+e5ubl5MZDfy7IsPn/+jG3b994/6tkY1PM8FosFruuexcjnSlVVyuUypVKJm5sbPn36dG/8wZptNBoJqK7rLwYUwHVddF3H8zxKpRKNRuPe+APYer0OwGKxOI+FJ1Bse8wS68E0jtfp//GoIAiI4rfvtdvtiKLLqE9i27+POU9G46ckCAKSJJHJZAAIgoAgCNjtdgAXA76vg2BFUUSSJHK5HK9evUKWZTzPw7ZtXNfF8zzCMLw44INg0+k0hUKBer3Ox48fKZVKOI7DYDCg1WphGAaO4xAEwbHtfZYOgk2lUiiKQrlc5u3bt1SrVTabDaqqJh9iOp1iWRau67Ldbi/CywfBSpKEoiioqoqqqhSLRYrFItlslnK5zPX1Ne12G03TGI1GmKZ5bLsP0kGwcXBKpVLJ+k2n0yiKQqlUolKpoChK8qTTaWzbxvO8e0Hs3Do4Gv9IkiRRKBR49+4dpVKJer2Oruvc3t4ymUxYLpdsNhvg/BH7qLCCICAIArIsUy6XyWaz5PN5isUioijeW8ur1Qrf9wnD8JgmPKmjezYGjqKIbDaLJEmoqko+n6dWq6HrOv1+n3a7zXK5PGsxcnTYWDG0oijIskwul6NQKFAsFsnn86RSKcbjMZZl4TgO2+325OAn8ez3EkURURQpFoukUikKhQKVSgVd19E0jcFggGVZbDYbgiA4GfDJPLuv+ANkMhlkWSafz3N1dZWkq3Q6zXA4TNay53lEUXR06LPA7ksURQRBIJfLUa/XKRaL1Ot1NE2j1+vR6XQYj8dJijom8NlhgSRix5VY7GFVVVEUhUKhgGma2LbNer0+Wp191mn82HtZlhEEgWq1Sjqdplwu8+bNG9rtNsPhkPl8jm3bCfBzoH+JZ/cVA19dXaGqKtVqldevXycVmSzLzGYzbNt+dsT+5bD7kiQJURSpVCp8+PCB6+trxuMx3W6XVqvFaDRKpvUhuijY73Ozoijkcrmkvs7lcui6nlRfP1tjXxxs/BtH7FQqRSaTSXZXvu8noC8adl/xWpZlmWw2S6FQIJPJoGka8/kcz/Pwff+n/vPv8celKAiCpLc1nU7RNA3LsvA876A98cXBxmllt9uxXq9ZLpcYhkGr1aLX6zGZTA6OyBcFG0URu90O3/dxHIfFYsF4PKbdbtNsNjEMA9d1f4/UE4Yhvu9jmia3t7f0ej10XWc6nbJcLlmv189q6fxS2Lj825+yi8WC4XBIu91OQG3bPsrG4CI86/s+8/mcfr9Pp9Oh3+8zm81YrVas1+uj7XHPDrsfgDabDa7rslgsku1dq9ViOBwmhcOL2+LtGxxFEWEY4rouo9Eo2ccahoFpmtzd3Z0EFM7s2e12y2azSfJmv9+n2+3S7/cxTfNeW+YUrZmzwUZRxHq9ZjKZJH3k4XCIaZpJL/nFNdz2FYZhUgUtl0vG4zGGYaBpGq1WK2myxXnz1C3Vo8PGUzCKIjabDavVKsmbhmEwmUwwTRPLss7SPt3XUWFjyO12i+u6WJbFdDplOBzy5csXRqNRUhycal0+paN7NggCbNum1+vR7XbRNC1pk8Y580UdbMXpY/9Zr9c4joNlWUwmk+TIMg5Cx2iYPVcHwYZhmEzVeF36vp/0i3RdZzQasVgskhP4F3sYHXtyuVwym80IwxDHcej3+3z9+pXBYIDjOD/dSTi1DoLdbrfc3d3R6XRwXZd0Op14eLVa4bruxd2ngANhd7tdAhx7LwiC5Lz1ku5E7evgAAX/Rl64rEtfP9KDhptlWcC3S4//pf2ofEmgse0xS6wHsIPBAIByuXwGs06j2PaYJdYD2GaziWVZZDIZarXa//LwpUhVVWq1GplMBsuyHlyy/qMuVz8KG+uPuDb/u+qPOv74C/u76h9Q7EV+68vCZwAAAABJRU5ErkJggg==) + **Play** | Inicia o reanuda la reproducción del deck | + + | ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADkAAAA4CAYAAABHRFAgAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAADmElEQVR4nO2a226qQBSGfzlGxARTmzTKjU30EXz8PkAfwUsP1QRFWhCEGWb2xc5MUDGt7MambL7EG3E563PNjBdrtcbjMUfNUX46gXvQSNYF7dqDyWQC13XhOM4986lMEARYLpeYzWYXz1rnF49t25hOp79G7pwgCPD6+oooiuR7F5UUgmmaYr/fI47juyZZFcuy0Ov14DgOptMpXl5e5LOTMzmZTKTgarX6NYIAEMcxVqsV0jSF4ziYTCby2Ymk67oAgP1+f98MvxGRu3ABzrarOIe3VrDVasnXNRhjAADOuYwBAEW5fsFzzuXrq4jci3fK1dv1K4hEVVWFoihQVbX0c4wxMMaQ5/lJrIi5JlqMu1W2SGVJUTlFUaDrOgzDgK7rpdWklCLLMmRZJkVFnGma0HW9dI2yuCqi/1RJRVGgaRra7TY6nQ5s2y6VTJJEXulpmgIAdF1Hu91Gt9tFp9Mp/f7j8YgwDKWYqOitVJIsVtA0TTw8PGAwGODx8RGadvqVnHPsdjus12t4nicr0m630e/3MRgM8PT0VLqO7/t4e3vDZrORZ63Ktq1cSSFpWRb6/T5GoxFGo9HFluWcYz6fgzGGOI5lsiLu+fkZ4/G4dI3FYoE8zxGGISiloJSi1WrdV1LTNBiGAdu20e/34bouDMO4kDwej9hsNjBNU1baMAx0u10ZV7bNsyzDZrOBYRjQNA2qqoJSenuuVSV/E41kXWgk60IjWRcaybrQSNaFRrIuNJJ1oZGsC41kXWgk60IjWRcaybrQSNaFRrIuVO5qiTZ3lmWI4xi+78sOVBHRn4yiCGmayv4kIUTGrdfr0q7WdrtFGIYghCDPczl3cCuVJTnnoJQiTVP4vo/FYgFCSGkT1vM8eJ6HJElk6y1JEux2O8znc9l3PEfExXEMSikYY/frNHPOwRhDlmVgjGG73YIQgu12C1VVLxI5HA54f3/H4XC4kKSUYrfbla4j4uI4BiHkZyoptp5IPgzDi1GXYsWL2zXLMkRRBEIIPj4+SitJCJFxYhLkroMRxWEF4O8ZTdO0NFlxfouDDeI853kuhyXK4iilPzfiIhCVYoxdHVgSFSgmWYwrzvd8FleFE8kgCOA4DizL+tJUVnHxa4l+hqjwd2FZFoC/LoKT/8nlcgkA6PV637bovRG5CxfgTHI2myEIApimieFwKH+V34BlWRgOhzBNE0EQnAz3/hdDvReSgjqNZ/8BZqdzihfQgggAAAAASUVORK5CYII=) +** Pausa** | Pausa el deck | + + | ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADcAAAA5CAYAAACS0bM2AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAADBUlEQVR4nO2aMW+jMBiGXyMXh0ArUMZkyFLUpWt+fn9Ax4yZqihUShUFpKZggw03nGy1VZcY3+XI8UgsDK+/R7YBfZjc3993uFK8SxfwJ6E/3UzTFIvFAnEc/+16rCiKArvdDpvN5st98nlZRlGE1Wo1GKnvFEWB5+dnnE4nAN9mTosJIZDnOcqyvEiR5zKdTpEkCeI4xmq1wtPTE4BPey5NUyOWZdlgxACgLEtkWQYhBOI4RpqmAD7JLRYLAECe55ep0AG6du1i5PQ+G9KMfUfXrl2u+lUwyg2VUW6o/Pj5dQ6EEHieZy4XtG1rLgDoOrtv+15ynueBUgrGGCaTCXzf7xNnaJoGnHMIISClhFLKKsdazvM8hGGI2WyGJEkQRZEzubquUVUV8jzH4XDA6XRC27Znz6CVHCEElFLMZjM8Pj5iuVwaOUKITaSh6zpIKVGWJbbbLdbrNTjnqOv67CwrOc/zwBhDkiRYLpd4eHhAGIagtPcWBgAopSCEAKUUr6+veHt7s1qe1nJBECCKItze3iIMQ0wmE6cPFEop7u7uEEURgiAA5/zspWktd3NzA8YYfN8HpdSZmM4nhMD3fTOGTb51RYSQL5drdGaf/Kt+if/Tcn1XxD8t15dRbqiMckNllBsqo9xQGeWGyig3VKzluq6zbrmdO4btOFZybdtCSommaVDXNaSUpsfoAt0k0vlN01jlW8tVVYWPjw+UZQkhBJRSzmZSKWXae3oMm3yrHkrbtubX8na7Nc0cl31LzjleXl5wPB7BObdqzFr34qSUOBwOWK/XyLIMURSBMWYb94W6rlGWJY7HI/b7PaSUVjlWcl3XQSmF9/d3cM6x3+8RBIHTdnpVVeCcX6adDvxenvqBIoRw2rdUSllLaXq3iPUsunxa6ty+uOl/w00xrhm/UIbKKDdUjFxRFAB+HxIbKrp27WLkdrsdACBJkguU5QZdu3YxcpvNBkVRgDGG+Xw+qBmcTqeYz+dgjKEoCnOo9KoPk5KfDnBf5THga+P/eBVcI78AY3SmYBYyiC0AAAAASUVORK5CYII=) +** Stop** | Detiene y libera el pipeline del deck | + + | ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADgAAAA5CAYAAABj2ui7AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGR0lEQVR4nO2aXU/byhaGH3/EMQSS0FJECRcUqVz3ip/fH9DLFiHxlZgkhDhAYhvHcfwRnws0s0N3zzk4ddLSvV8pimVLnvXMmllrzYyVjx8/ZvzBUn+1AcuW/qObR0dH7O/vU6/XV23PQnIch263y/n5+d+eKfNDdGNjg+Pj41cD9r0cx+HLly/4vi/vPfOggJtOp4xGI4IgWLmRi2h9fZ2trS3q9TrHx8d8/vxZPpNz8OjoSMLd3Ny8GjiAIAi4ublhOp1Sr9c5OjqSzyTg/v4+AKPRaPUWFiRhu2CBOUAx716T576XsH0+hvwz08T/k6IoqOpT32RZJn+/oxYC1DSNSqUCQBiGxHEM8FtC5gLUNA3DMKhWqzQaDQzDIAgCXNfFcRzG4zFpmhYCqigKmqZRKpUAiKKINE1zvycXoGEYbG5usr+/z6dPn6hWq/i+T6fT4eLigjiOmU6nCxkyL0VRUBQFwzDY2NhAVVVc1yUMw9ydlwtQ13UqlQrv3r3j8PCQnZ0dJpMJ1WqVLMtQFIX7+3uCICBJEmazWS5j4AlO13VM0+TNmzfs7OygaRqdTofhcJjbk7kAVVVF13VKpRKqqmIYBoZhcHBwgGma7OzscHJyQq/Xw/O8hXocniqT3d1dDg8POTg4eNbmcDhkPB4vB1DMC03TUFVVQqqqiqZpmKZJkiSUy2X6/T4PDw9MJpPc81LXdTY3N9ne3mZvbw9d12m32wwGAzzPy2PyYlFUSMyVUqlEtVqlVCqh6zr1ep3Ly0s0TaPf7zOZTHIBqqpKqVTCNE3K5bIMbrquo2laLht/CvB7g4QXxXWpVMIwDO7v7/F9/0VDVnSaqqryWrQh7uVRIYDCMAH69u1bNE2jVqtRq9WwLItOp4Nt23K4/i9QAZEX5kcqDBCQkXR9fV3mSxGIVFUlyzIcx5GpZBWFQaGA8z2uaRpra2s0Gg1M06RWq7G9vc3Xr19lKvnZfPkSFQ447xVN02SiNgyDtbU14jim0+kwGAxwXZc4jpnNZkvzZqGA8Pd5oygK5XKZer2OrusoikKtVqPZbNJut3FdVybvZUAWDig0DyrSh2maVCoV1tbWUFWV2WyGoiiyKEiSpHA7lgb4vUSUNU2Tvb09KpUKu7u7nJyccH19LauforUyQCGx1BLRNY5jWYbd3t4ymUwKbW+lgPNJ2zAMarUaHz58kCkkTVNs25aL6SK0cg/CX9VKuVzm/fv3ZFlGEASMRiMcxykkwQv98XsyvwxwNpsRRRGDwYBer0e/32c0GhFFUaHpYqWAogadzWYkScLj4yOWZXF5eUmr1cK2bcIwXGih/N+0sjk4DzedTvE8D9u2OTs7w7Isut0urusWGmBgxUFGDMter8fl5SUXFxd0Oh1c113ahvPSAOfnkfDaeDxmMBjQarW4urri+vpari6WUcXAijwYRRGPj4/Yts23b99ot9v0+30cxyGKotdVbMNf3kvTVB7F3d7eYlkWp6enDAYDxuPx0rw2r8IXvEJJkjCZTOj3+7RaLZrNJpZlPduIWoWWsqIPwxDP8xgOh1xdXWFZFtfX1/T7fblfuqpt/sIXvGma4nkelmVhWRZXV1fYto3neYUn8ZeoEMAsy0iShDAMcV1XbuU3m01ub2/xfZ8kSV4MN58zf3SdRz8NmGUZcRwzHo95eHjg7OyMVqtFr9fj7u5ObuPngZvNZsRxTBRFRFGEqqpEUbTQcUAuQNF4mqbP/n3f5+7ujna7zenpKd1uF8/zCIJgoSEZxzG+7zMcDrm7u0PXdTzPYzKZ5I68uQBFz4qejKKI6XRKp9Ph9PSUZrOJbdv4vk8cxwvPNxF94zhmOByi67qMwGEY5npXLsAkSQiCgPv7e1mFBEFAu93m4uKCm5sbubeyKJwY8o+PjzLViEOXRQrxXICi4V6vJ/dXgiBgOBxKA34Gbl5pmhKGIWmaoigK0+l0ofSS24NJksigAk9H2CL8F5UCxHviOJbH44tqoSiaJIkEfMlZw6/UQoAi74nr31kL58HfHUxILp8dxwGejo9fq4TtggXmALvdLgBbW1srNqs4CdsFC8wBnp+f4zgO5XKZRqPxqjy5vr5Oo9GgXC7jOM6zD2P/+A9ilR99lP7HftL8J+rfrfvXrv8AKRQ//e8B4KoAAAAASUVORK5CYII=) +** Siguiente** | Salta al siguiente tema de la lista | + + | ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADUAAAA3CAYAAACsLgJ7AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGqElEQVR4nO2a23aa6hqGHzaCbETAJJqq6eguPeh5Lr8X0Avo6EibdEfdi6iICqLrIAtmkzRrJk1MZrPmO4YnwJDv4dv+PwivXr3a8MgkPrQB25B88cDh4SGNRgPbth/CnhsrCAI8z+P4+Dg/JmThZ5omR0dHfwzMRQVBwLt37wjD8C9PZUDL5ZLRaEQURQ9p47Wl6zqO42DbNkdHR7x9+/YM6vDwMAf68ePHQ9t5I0VRRBRF1Ot1bNvm8PDwrFA0Gg0ARqPRgxp4G2W2NxqNM6gsj/6UkPuVMttt236cJf1fqD9Fl5pvpkKhQLFYRFEURFEkSRLm8znL5fLcdaIoUigUkGWZQqGAJElbNxpgtVrlv4s2XQlVLBZxXRfbtlEUhclkQq/XI45jNpu/xkVJktA0DcMwsCwLRVG2R/KTFosFYRgSRdH1oRRFoVwu8+TJE2zbJgxDDMOg3W4ThiHL5ZLVakWhUMCyLHZ3d9nf38cwDERx+1EdBAG9Xg/f9y+1oiuhBEFAkiRM0+T58+dIksSLFy/wPI+PHz/S6XSYTCaoqorjODSbTV6/fo3ruvcSgu12m2KxCMDnz5/PnfufUKIooigKjuNgmia7u7uUSqUcttvtsl6vURQFQRBQVRXLstA0jUKhsFWoJEkYDAboun7p3JVQ5y6S5TxvFEVB0zSq1Sqe59HtdomiiH6/T7/fxzRNFEX55c3uUrIsI0nSL0P9WlBA7jVZltF1HcuyKJVKlEolOp0OcRwzHA6RJIkoinBdl1KphKIoV958W7o2lCAI50KyXC4jyzKWZbG3t4fv+wyHQ75+/Uqv18O2bZ49e4bjOGialrcGQRC2yQPcACpTZpSiKCiKkpdyy7LyOB+Px0RRhCRJzOdzbNvOc02SpPwBbUs3hsr0M1wWZqZpMhgM8vx6//49uq5Tq9VoNps0m010XUeWf/u219Kt/10QhHyi0DQNXdcpFotIkpT3sqyPJEmC67pYloVhGMiyvBWv3ckjy4ySJAld19nb26NYLGJZFr7vMx6P6ff7jMdjdnZ22N/fp1qtYhhGPlrdJdidxUFmlKqqKIqSTxntdpsvX74wnU7p9/ssl0vSNGW9XuO6LqZpnutrdwG3teAWRRFVVXOP1Ot1+v0+vV6Pb9++cXp6SqPRyH+u695Z2d8KlCAIbDYbRFHMy7mu62ialheJIAiYTqe0Wi3iOGY8HmPbdt7gb1P+t+apn/NMFMU8b2RZPlclfd9nMBhgWRYvX76kWq1iWRaqqv72vbdbW/+rbDgulUrntrSyPOv3+2iaRrlcxjRNdF2/FdT/18r3LrXZbFiv1ywWCyaTCUEQMBgMWC6XmKaZl/9KpZJPHbfRVqGyFXKapvnOb7vdxvM8fN/PV9eu61KpVHBdN+9dt9HWoDLvxHFMFEVMp1O63S6tVotut5vPhLVajYODA3Z2du5s4N0K1M9AnU6H79+/43ke0+kUTdNoNptUq1Vc16VcLt/5FsCdQWWhtlqtiKKIMAzzcu37PkmSYBhGPibVajUMw0BV1X/umARnYIvFgsFgQKvV4vT0lDiO89yp1WpUKpW8dMuyvJU11q2hNpsNq9WKOI6ZzWb4vk+n06HT6eD7ft5/Dg4OODg4yGH+keupLNySJGE2mzEcDjk5OWE4HBLHMZIk8ebNGyqVCo7j4DgOhmHcy07Tb0FtNpt8x3YymeRL+eFwyHK5pFgsYts2T58+pVKp3Mk8dxPdGCqrbFEU0e128TyPVqtFFEXYtk2j0WBnZwfXdXEch2KxmOfOfenaUGmakiQJi8Ui3x31PI92u810OkVVVVzXzb1TLpdzz9yHd37WtaDSNGWxWLBarRgMBnz69Cnf80uSBNu2qVQq7O3t5d4BWK/XWzM8TdN8sXlRV0JtNps8d6bTKfP5nOl0iud5fPjwgVarxXg8RtO0cxUt25fYtneCIGA2m7FYLG4GlaYpYRhycnJCFEW0Wi3a7Tbj8Zj5fE6SJAiCwGg0wvM81us1pmneS/6MRiO63S6+7186dyVUHMdMJhNkWWY0GhGGId1ul9FoRJqm5zw5mUzYbDZEUYSqqveSQ9k8OZvNLp27Emq5XOL7PrPZDEmS8uaaJMm569I0zY+HYXivL92SJLlkD/yNp+I4/ts/zxL24ouvh9SjXPn+C/WnSISzmg9s/UXZNpXZHgTBGZTneQA4jvNwVt1Sme2e551BHR8fEwQBqqpSr9f/KI/puk69XkdVVYIg4Pj4+HF+xChc/DD4UX1u+pj0H9a9TZHYrrY+AAAAAElFTkSuQmCC) +** Detener al final** | Completa el tema actual y detiene (no carga el siguiente) | + + | ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADYAAAA7CAYAAAAw23kDAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAJdklEQVR4nO2a71IbVxLFf90jkBAChLExWFQ2SW2o2hfw4+cB8nn/FZXd1CY4YIyRwEhIQnN7P3RfSTgIBMLxxrVdpQ9SaWbuud19+nTfke+++874Ak0/9wI+lf0f2B/NKrf9uL+/z97eHs1m8/dez6Os0+lweHjIwcHB+DeZJo9Go8Hr16//MIA+tk6nww8//MDl5eVNj2VQg8GAdrtNr9f7XGt8kNXrdTY3N2k2m7x+/Zrvv/9+Amx/f38M6s2bN59znQ+2Xq9Hr9ej1WrRbDbZ39+fkMfe3h4A7Xb7sy1wUctr39vbm3gs59WnCT/BCVgA++jzkHtku/26vPZms3k7Ky5mFUARKYACB1RMfRJQIpIwS+Pv/kmYjZhsgACCyPL4vv6fQfx/9uY8KTAHU0NkFZE6UJ9a1NIUsASMgFEsso9ZF7MuIlcBGEQUqKK6BazF9QPMTkmph8g1ZuWnBJZDbRmRZ6juUhS7iLxApIHICmZ5x8F3exSALknpHSkdkdIRZieIXOOeUVTXKYq/INICljFrk9LfgbekdB73+gTARGqIbCDyDJEtVLdQbSKyHqCqwBIiipnENRY7XQVqqNYQWUd1B7M2Zu9JqY3IAJEVVF8gsgUoZgmzbUQuEOliNnxKYO4hkTqqz1DdQWQXkZdjMCIeemYGuAeyOUD/qNYxW8ZsDdgGupTlW+AYs3NEasAmIquAYbYCrGNW5SahLAwsh10V1V1UW6i+QnUHWEdkCRGJPCkx6+EhN8TDTyIXlxFZAVZwj9ZwIlinUqmTUoOUOpiByBqwhFkZ968hUowj4ImAZVDrqH5LUXyN6jZOFASYIWaDIIYOZudAbypsloE6quuIbAArUyG7DGyj2kCki3u7BhSIJFIyRGYDejQwD79dVL9B9StEnsVCDU/4U8rymJTektIZ0AeGTIejmcajq4hUIy9fIvKSongRIJYjL0ucUe8HswCwGiKbqL6iKL5BZDPCImHWoyzPgtmOSOkkgJURlkbeaDPwkFTMCsw6mF2g2gWugohqiFTCg5lN5wf3AGCCyDpFkfNqGw8dw6xLSieU5b9J6QSzM8wuI78gF1Gzm/czU0QqmF2QkmE2IqUrisJQfY5IBa9d3HL9EwBzMlBUtxB5FSy4iofehwD1E2X5T8wugOHMwjkNzNnTiWFSfDuxIUPMVpgjnR4PzKl9KYBt4+wHKSVSalOWPzEa/QOzDs586c67efIvR6n485hAPAIqiKzjrPlIVPMBK3DWakZOrSLi1OvS5ijC72IK1H0xU4Tk2kL1T4isBSsWuCeXcHrXGx4T8U1JKd3ryTmBOROKNFFdiZ0cBPMdYXaGyHBMEveZs+ISqvVQKKsfkYTiUWJkyWRmmF1j1geuMLu+81lzARNZDWDrmFUQMVJyYGZvMfswR05NTMRi0S6ERSas6QThQvkmWRheC88xO8Wsf+cz7wU2UezPEWmgWsHbjiFmZ6TUnmK/eW0UrPmelH4FtoJElplF6SmNMDsnpbeU5c+YXSAymsmUc3hMUF2OPKhFuPVJqY0X3/k9lc314zDq3N9IqQ5UYhNvB+bitx/RcR4hOZuk7gGWdeFS6Dr3ltlVPCDT+mOm5GXUv2s83PWW3c+NpmJWQ3U5fl9BJBf+hYAV8VfBw2gC7HGgiOuugwRmFV/BO4QaRbERqqeKWS9CuHxcoykiwYAWOaV4Ee1Gk3ezHXl6y+y5jkiLomghUo/87I8L+W2beycwd/UwkvYNIk3M+qR0SkrHQDd04qcABR6GK4jsRK/3EtU1zAaI/AK8A7o8GJg3dtek1GE0+hGRRtSSs1AZwzvjfFETyYpnIxrY+jjXvYj7Gm+zOVhxBHwgpf/gskeBYejH6tQDpm16vHa9AMEITlxrAaoS0ZEL90IFuiSHoxfWBrAW+u42s6Dm67iuA/TwDXqo+YDIn1cPiXXNpCOfDW7OAr0ENFD9OtT9Jj4am2UjzHqYXVCWB5Tl8Z2LmPFkvAx4DwirMQ64ijo2YIE6Bk71qxTFXvRhu+G1WcC8kxYZApuk1Eb1MurV9fywpIpPv7bGTadvTo+UToAr7hIHcwDTYKbn8ZANVFfHTOj6LotfC1WRogOo4EOaPH6bF5biHnoRvV8trh+Q0gdSOsasuxgwM28jnJVqTIYu+R+5hRjFFAnMyvDQAPfS3SrhpmXC2EB1h6J4Fc8k7ndOSr+GQJidt3NpRS/S3kr4OOwG9CCKfsR/GWD6sbunsbvzkIfgVN7Ah687iGwHGw5DcbRvqI5ZfdkTjLhd0JqdUZb/CkXSn2LFdgjW++Iwa9IGRbEfRPUiyAvMLjB7G/k1wuzuZvOJZvfKRFOOSOkSs0u8f7rmbm9JhFo9yOI5ql+juoNqnqsMo1s/xuyMSfgvVMc+tkwSMBkDLAEbqO6RBzM+DxlMAb85fpuEdxH59Dxk0y6qL1Bt4AL4CrPO+NDC26X7WegRA1OJNt11pAvlApEmPuTZwAvqGiKnmF3hxz25lbeogXnMXUPkGUWxE6BeBrWnAHVGWf5CWf5CSu/wYn+/PRiY07mF+D3H54Or49rmZ1kNzL6iKAahPD7g519D3Mt5br9GHrqq1uL3SjxnEETxM2X5V8zeP6hTf1SO+ZB0SFke4l5rRBjlQ4k8i0+Y1YCNUAoj3GsFfoS0gterfAoqmI0i9E4xO6Ysfyal99xXkB8BLGu/rM3ycWo/JlTn+Dz/KqbDjXFRzkdN3vH6gMYXD146hEw4Ps4bRPgdB6gjUnoXnnqYJJsDWMIVepd8uCBSktIFPsw5CUq+xMdwm0EGG7g3qriQLcgD0MnBXz7V7GL2AS++bcxOSOk9k6nww20OEWz40OYMPyNeDQ++J3fT3ny+Q+THkEGvgFeI5DOzeoDL+jI3sFd4S3QSjPdrFN95B68LAPOw65LSEVBSlqv4XNHzwFVGXkAZ3XYZ3vsJn2z5ZFe1wEPbveX1aYjXu+y18t7i+yTAPGz8AK8s+9FYGpODvUlCu8S5ii77gunXIUQqlGUOxRTgPGfz6xBOHFkmLTZvmMNjuSC7oJ1oxY/fschHRT7dvWnC5NzZ7+kb9tvFL+qpbOOmqtPpAP7C1WzLo+d066Jmm/HbF1WefgKU197pdCbADg8PAdjc3HzyB/5eltd+eHg4AXZwcECn06FardJqte7x3P+W1et1Wq0W1WqVTqfDwcHBl/siptz2evoX9+rsl2Rf7FvcXyyw/wIHJpw9lNrGywAAAABJRU5ErkJggg==) +** Loop** | Activa la repetición infinita del tema actual en ese deck | +Se puede hacer **clic en la barra de progreso** para saltar a cualquier posición del tema. + + Se puede **arrastrar un tema** de la lista de reproducción y soltarlo sobre un deck para reproducirlo inmediatamente con crossfade. +**Controles globales** +La barra inferior contiene los botones principales del sistema: + + | **Botón** | **Función** | + + |-|-| + + | **▶ Iniciar** | Arranca la reproducción desde el primer tema de la lista | + + | **⏭ Siguiente** | Salta al siguiente tema (crossfade inmediato) | + + | **⏹ Stop** | Detiene inmediatamente toda reproducción: música, comerciales y eventos | + + | **🕐 Hora** | Dispara manualmente la locución de la hora actual | + + | **Pisador** | Dispara manualmente el pisador sobre el tema en curso | + + | **↓ Duck** | Baja el volumen de la música para permitir locución (toggle) | + + | **🔍 Buscador** | Abre el buscador de audio | + + | **Pautaje** | Abre el editor de pautaje de comerciales | + + | **Parrilla** | Abre el editor de parrilla musical | + + | **Playlist** | Abre el editor de playlists .gradio | + + | **Botonera** | Abre la botonera de efectos de sonido | + + | **Visor** | Abre el visor de programación del día | + + | **Reportes** | Abre la reportería de audios emitidos | + + | **Config** | Abre el diálogo de configuración | + + + + La etiqueta junto a los botones muestra: Vol: XX% | Duck: XX% | Fundido: Xs +**Lista de reproducción (playlist)** +La cabecera del panel contiene, además del título, dos botones en la esquina superior derecha: + +| **Botón** | **Función** | +|-|-| +| **Vaciar** (ícono escoba) | Borra inmediatamente todos los temas de la cola. Si el llenado automático está activo, el daemon los repondrá en el siguiente ciclo (5 s). Si está pausado, la cola queda vacía hasta que el operador la llene. | +| **Automático** (ícono robot) | Pausa o reanuda el llenado automático por `playlist-refill`. **Verde oscuro** = automático activo (estado natural). **Rojo oscuro** = llenado pausado. El estado persiste entre reinicios. | + +***Modo Automático pausado (rojo)*** + +Cuando el botón **Automático** está en rojo, el daemon `playlist-refill` deja de agregar temas a la cola. Situaciones de uso: + +- **Noticieros**: los comerciales siguen cargándose normalmente para los cortes, pero la parrilla musical permanece vacía. El operador la llena manualmente cuando quiere retomar música. +- **Control manual**: el operador elige personalmente qué temas se reproducen, sin que el sistema los rellene automáticamente. + +Si el player está detenido (stop), el llenado automático está en rojo y la parrilla está vacía, al dar **Play** el sistema reproduce únicamente los comerciales en cola y se detiene al terminarlos. + +La lista muestra los temas pendientes. Cada fila contiene: +- **Número azul**: indica la posición en la cola; al hacer **clic** reproduce ese tema con crossfade +- **Título del tema**: el tooltip muestra la ruta completa al pasar el mouse +- **Botones de acción inline** (semitransparentes; se iluminan al pasar el mouse): +- **▲** Subir una posición en la cola +- **▼** Bajar una posición en la cola +- **🗑** Eliminar de la cola +Adicionalmente, **clic derecho** sobre cualquier tema abre el menú contextual con opciones avanzadas: +- **🎧 Preescuchar (CUE)**: escuchar en la tarjeta secundaria sin salir al aire +- **🕐 Insertar Hora aquí**: inserta una locución de hora en esa posición +- **📡 Insertar Streaming aquí**: inserta una URL de stream en esa posición +- **📋 Cargar lista .gradio aquí**: inserta una playlist guardada en esa posición +- **🔄 Regenerar lista**: vacía y vuelve a llenar la cola desde la parrilla actual +- **🗑 Eliminar de la cola**: quita el tema seleccionado +- **Doble clic** sobre un tema → lo reproduce en el deck activo con crossfade +- **Arrastrar dentro de la lista** → reordena el tema a la posición soltada +- **Arrastrar sobre un deck** → envía el tema directamente a ese deck +Los temas se cargan automáticamente por el daemon playlist-refill desde la parrilla de la hora en curso. También se pueden cargar playlists completas (archivos .gradio) arrastrándolas a la ventana. +**Reproducción de hora** +Cuando llega la hora en punto, playlist-refill inserta automáticamente el ítem especial **Hora** en la cola. Al llegar su turno, el sistema busca los archivos de locución de hora en: + + $HOME/.gradio/data/panel/Time/ +***Importante*** *: los audios de la hora (por ejemplo* * * *HRS00.mp3 HRS04.mp3* *,...* * HRS08.mp3* *,...* * HRS12.mp3* * …* * * *MIN01.mp3 MIN09.mp3* * * *, o archivos con el nombre de la hora) deben copiarse en la carpeta * *$HOME/.gradio/data/panel/Time/* *. El sistema los reproduce en orden automáticamente al llegar a la marca de hora.* + + Se puede disparar la hora manualmente con el botón **🕐 Hora** de la barra de controles. +**Pisador automático** +El **pisador** es un audio corto (jingle, ID de emisora) que se superpone automáticamente cada cierto número de temas. Se configura en **Configuración**: +- **Activar pisador sobre temas**: activa o desactiva la función +- **Carpeta de pisadores**: directorio donde están los archivos de pisador (se elige uno al azar cada vez) +- **Tocar pisador cada N temas**: frecuencia de reproducción (1 = todos los temas) +- **Carpetas que no se pisan**: lista de carpetas cuyos temas no activan el pisador (útil para tandas de comerciales o géneros especiales) +El botón **Pisador** de la barra lanza el pisador manualmente en cualquier momento. +***Pisadores por programa (por hora)*** +Para dar identidad sonora propia a cada programa, la herramienta **Parrilla musical** permite asignar una carpeta de pisadores exclusiva a cada franja horaria. Esta carpeta tiene **prioridad sobre la carpeta global**: si está definida, el sistema la usará para esa hora; si no, usa la carpeta general de **Configuración**. Ver sección [Parrilla musical → *Pisadores específicos por hora*.](#anchor-16 "#anchor-16") +**Comerciales y eventos** +El daemon `comercial-scheduler` carga los archivos de pautaje al :58 de cada minuto leyendo: +- `~/.gradio/data/comerciales/{H}/{M}.com` — comerciales programados +- `~/.gradio/data/eventos/{H}/{M}.com` — eventos de inserción inmediata +- `~/.gradio/data/eventos-espera/{H}/{M}.com` — eventos en cola de espera + + Cuando comienza una tanda, se reproduce en secuencia: primero eventos (si los hay, interrumpiendo la parrilla), luego comerciales (si no hay eventos, se cargan y esperan a que finalice el tema en curso). Al terminar, la música vuelve a su volumen normal. + + El formato de cada línea en los archivos `.com` es: +`/ruta/al/audio.mp3|1234567|20260101|20261231` + +- **Campo 1**: ruta del archivo de audio (o URL http://...) +- **Campo 2**: máscara de días activos (1=Lun, 2=Mar, … 7=Dom); 1234567 = todos los días +- **Campo 3**: fecha de inicio de vigencia (YYYYMMDD; 0 = sin límite) +- **Campo 4**: fecha de fin de vigencia (YYYYMMDD; 0 = sin límite) + +**Panel de próximas tandas (nueva interfaz desde v0.4.0)** +El panel "📢 Comerciales" de la ventana principal muestra las **próximas 4 tandas programadas** en lugar de la cola activa: + +- Cada tarjeta muestra la **hora de reproducción**, la **duración total acumulada** de la tanda y un botón **▶ Play**. +- El botón **▶ Play** ejecuta la tanda inmediatamente: hace fadeout del tema en curso, reproduce todos los audios de esa tanda en orden y luego reanuda el playlist. La tanda queda marcada como "ya reproducida" para que el scheduler no la duplique al llegar al segundo :58. +- Los botones **▲ ▼ ✕** dentro de cada tanda editan directamente el archivo `.com` del horario (reordenan o eliminan ítems de la programación futura). +- Cada tarjeta tiene un color de fondo diferente (azul / verde / ámbar / violeta) para distinguir visualmente los bloques. +- El panel se refresca automáticamente cada 30 segundos. + + Los paneles **⏳ Eventos en espera** y **📅 Eventos** permanecen ocultos cuando están vacíos y aparecen automáticamente cuando se cargan eventos, con divisor arrastrable para ajustar el alto de cada sección. +**Procesador de Audio DSP Multiband** +G Radio incluye un procesador de audio broadcast profesional que puede activarse y configurarse directamente desde la ventana principal. El procesador es ideal para ajustar la sonoridad, la presencia y los niveles de la señal antes de que salga al aire. + +***Activar / desactivar el procesador*** +En la barra de herramientas superior, a la derecha, hay dos botones: + +- **🎚 (processor-off / processor-on)**: enciende o apaga el procesador. El ícono cambia para reflejar el estado. El estado se guarda y se restaura al reiniciar el programa. +- **⚙ (processor-config)**: abre la ventana de configuración del procesador. + +***Ventana de configuración*** +La interfaz de configuración muestra: + +- **Barra de presets**: combo con presets disponibles (ROCK, BLUE, CLASIC, VOICE, HARD BASS, STRONG) + botones **▶ LOAD** (aplicar preset) y **● SAVE** (guardar preset propio). El último preset cargado se recuerda entre sesiones. +- **Panel BANDS** (izquierda): selector del número de bandas (2–6) y slider de **IN GAIN** (-12 a +12 dB). +- **Bandas B1–B6**: para cada banda activa se muestran knobs ajustables en tiempo real: + +| **Knob** | **Rango** | **Función** | +|-|-|-| +| THR | -60 a 0 dB | Umbral del compresor | +| RATIO | 1:1 a 20:1 | Razón de compresión | +| ATK | 0.1 a 200 ms | Tiempo de ataque | +| REL | 10 a 2000 ms | Tiempo de release | +| GAIN | 0 a 24 dB | Makeup gain (post-compresor) | +| LVL (CLIP) | -12 a 0 dB | Nivel del clipper suave | + +- **Checkbox CLIP**: activa el clipper suave (tanh) para esa banda. +- **Checkbox ON**: activa o desactiva la banda completa. +- **VU metros**: barras de nivel (verde) y reducción de ganancia (azul) por banda, actualizadas a 30 fps. +- **Panel OUT GAIN / LIMITER** (derecha): slider de ganancia de salida global y umbral del limitador brick-wall final (-6 a 0 dBFS). + +***Cómo funciona*** +Cuando el procesador está activo, se inserta una etapa DSP en cada pipeline de audio (música, comerciales, eventos) mediante un pad probe de GStreamer. La cadena de procesamiento es: + +`Crossover LR4 → Compresor por banda → Clipper suave → Limitador brick-wall` + + Todos los pipelines activos comparten el mismo procesador; cualquier ajuste de parámetro en la ventana de configuración se aplica **en tiempo real** al audio que está sonando, sin necesidad de esperar a la próxima canción. + +***Guardar presets propios*** +Con el botón **● SAVE** se abre un diálogo para dar nombre al preset. Los presets de usuario se guardan en `~/.config/gradio/presets/` y aparecen en el combo junto a los presets de fábrica. + +***Archivo de configuración*** +El estado del procesador (on/off, parámetros, último preset) se guarda en: +`~/.gradio/data/processor.json` + +**Preescucha CUE** +Si se configura una segunda tarjeta de audio (tarjeta CUE), se puede escuchar cualquier tema de la lista en monitores o auriculares **sin que salga al aire**. + + Para usarlo: clic derecho sobre un tema en la lista → **🎧 Preescuchar (CUE)**. Se abre una pequeña ventana con controles de pausa y stop para la preescucha. + + La tarjeta CUE se configura en **Configuración → Tarjeta de audio CUE** (por ejemplo: hw:1,0). +**Duck (bajar música)** +El botón **↓ Duck** (toggle) baja el volumen de la música al nivel configurado para permitir locución del operador. Al pulsarlo de nuevo vuelve al volumen normal. + + Los niveles de volumen se ajustan con los archivos: +- ~/.gradio/data/tmp/upvol — volumen normal (0–100) +- ~/.gradio/data/tmp/downvol — volumen duckeado (0–100) +**Streaming de Internet** +Se puede insertar una URL de stream en la lista de reproducción: +1. Clic derecho sobre la posición deseada en la lista → **📡 Insertar Streaming aquí** +2. Ingresar la URL (http:// o https://) +3. El stream se reproducirá en su turno igual que un archivo local + + Los streams también se pueden pautar como comerciales en los archivos .com usando la URL como ruta. +**Configuración** +El diálogo de **Configuración GR** (Config) permite ajustar: +| | | +|-|-| +| **Campo** | **Descripción** | +| **Tarjeta de audio principal** | Dispositivo ALSA/PulseAudio de salida principal (vacío = predeterminado del sistema) | +| **Tarjeta de audio CUE** | Dispositivo para preescucha sin salir al aire (vacío = desactivado) | +| **Nombre de la emisora** | Aparece en el encabezado de la ventana y en los reportes PDF | +| **Segundos de fundido (crossfade)** | Duración en segundos del fundido cruzado entre temas (0 = corte directo) | +| **Pisador sobre temas** | Activar/desactivar el pisador automático global | +| **Carpeta de pisadores** | Directorio global con los audios de pisador; se elige uno al azar en cada disparo. Puede ser anulado por hora en la herramienta **Parrilla musical** | +| **Tocar pisador cada N temas** | Frecuencia del pisador (1 = después de cada tema, 2 = cada dos temas, etc.) | +| **Carpetas que no se pisan** | Lista de carpetas cuyos temas **no activan el pisador** automático. Estas carpetas también quedan **exentas del filtro de no-repetición** de 3 días (útil para jingles o géneros de alta rotación) | +| **Carpetas Nacionales** | Carpetas con música nacional (usadas en el cálculo de porcentajes en reportería) | +| **Carpetas Intercultural** | Carpetas con música intercultural (usadas en el cálculo de porcentajes en reportería) | +| **Detector de silencio (segundos)** | Si el audio lleva este tiempo detenido o en silencio, el sistema salta automáticamente al siguiente tema; 0 = desactivado | +| **Volumen principal (0–100)** | Nivel de volumen de la música en reproducción normal. Se guarda en ~/.gradio/data/tmp/upvol | +| **Volumen duck (0–100)** | Nivel al que baja la música cuando se activa el duck (para locución o comerciales). Se guarda en ~/.gradio/data/tmp/downvol | +| **Puerto servidor remoto** | Puerto TCP en que el servidor escucha conexiones de los clientes remotos (default: **7777**). Debe coincidir con el que configuran el cliente de escritorio y el cliente Android | +| **Token de acceso** | Contraseña que deben enviar los clientes al conectarse. Si se deja en blanco, cualquier cliente en la red puede conectarse sin autenticación | +| **Relay internet** | Activa la conexión al servidor de relay configurado vía variable de entorno `GRADIO_RELAY_URL` para permitir acceso remoto desde internet (fuera de la red local). Requiere configurar el **ID de relay** | +| **ID de relay (8 dígitos)** | Identificador único de esta emisora en el servidor relay. El botón **Generar** crea un ID aleatorio. Este mismo ID debe ingresarse en el cliente remoto para conectarse por internet | + +Los cambios en Configuración se aplican inmediatamente al guardar, sin necesidad de reiniciar el sistema. + + El archivo de configuración se almacena en ~/.gradio/data/tmp/gradio.config. + + + + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANElEQVR4nO3OQQmAABRAsad4EFMY9fewnUms4E2ELcGWmTmrKwAA/uLeqrU6vp4AAPDa/gDzYgM3ZPdzEgAAAABJRU5ErkJggg==) +**Parrilla musical (gr-parrilla)** +La **Parrilla Musical** permite programar qué carpetas o archivos de audio se reproducirán en cada hora de cada día de la semana. +**Uso** +1. Seleccionar el **día** (Lunes a Domingo) en la columna izquierda +2. Seleccionar la **hora** (0 a 23) en la columna central +3. La lista de la derecha muestra el contenido programado para ese bloque +**Tipos de entrada** +- **Carpeta con ** ***** (p. ej. /home/usuario/Musica/Variados/*) — el sistema elige un archivo aleatorio de esa carpeta o sus subcarpetas en cada ciclo +- **Archivo exacto** — se reproduce ese archivo específico +- **Hora** — marca especial: inserta la locución de la hora en ese punto de la cola +***Selección aleatoria sin repetición*** +Cuando una entrada apunta a una carpeta con subcarpetas, el sistema reúne todos los temas disponibles de **todas** las subcarpetas en un pool único y elige uno al azar, dando igual probabilidad a cada tema independientemente del tamaño de cada subcarpeta. Los temas reproducidos en los últimos 3 días quedan excluidos del pool para evitar repetición; si la carpeta se agota completamente, el sistema relaja esta restricción de forma gradual. +***Carpeta "No Tocar"*** +Cualquier subcarpeta cuyo nombre sea exactamente **"No Tocar"** (sin distinguir mayúsculas) es **ignorada automáticamente** por el sistema de selección aleatoria. Esto permite mantener audios de referencia, versiones descartadas o material en revisión dentro del árbol de música sin que entren al playlist. +**Edición** +- **Arrastrar archivos o carpetas** desde el explorador de archivos hacia la lista +- Los **botones de ícono** en la barra superior actúan sobre el ítem seleccionado: +- **▲** (up) sube el ítem una posición +- **▼** (down) baja el ítem una posición +- **🗑** (trash) elimina el ítem +- **⏰** inserta la marca especial *Hora* +- **Leer** carga la parrilla guardada del día/hora seleccionados +- **Grabar** guarda la lista; los cambios se aplican en el próximo ciclo del daemon +- Los bloques de hora/día se pueden **copiar** arrastrando un botón de hora sobre otro (o sobre un día) +***Pisadores específicos por hora*** +En la parte superior de la ventana, a la derecha del título "ELABORACIÓN DE LA PARRILLA…", aparece el campo **"Pisadores esta hora:"** con un botón 📂 para explorar carpetas. Permite asignar una carpeta de pisadores exclusiva a la franja horaria que se está editando. Si se deja en blanco, el sistema usa la carpeta global configurada en **Configuración**. Este valor se guarda junto con la parrilla al presionar **Grabar**. +Los archivos se guardan en: +- ~/.gradio/data/parrilla/{dia}/{H}-{H+1}.mus — lista de entradas de la parrilla +- ~/.gradio/data/parrilla/{dia}/{H}-{H+1}.pisador — carpeta de pisadores para esa hora (opcional) + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OMQ2AABAAsSPBCj7fFC6wwIgHRiywEZJWQZeZ2ao9AAD+4lyruzq+ngAA8Nr1AOIABebqJIqXAAAAAElFTkSuQmCC) +**Pautaje de comerciales (gr-pautaje)** +El **Pautaje** permite programar los comerciales, jingles y eventos que se emitirán en cada minuto del día. +**Estructura** +La programación se organiza en tres categorías: +- **Comerciales**: tandas de publicidad en los cortes programados +- **Eventos**: audios que se insertan inmediatamente a la hora indicada +- **Eventos en espera**: audios que quedan en cola hasta que el operador los active +**Uso** +1. Seleccionar la **hora** y el **minuto** del corte +2. Agregar los audios arrastrándolos o con el botón **Agregar** +3. Para cada audio configurar: +- **Días activos** (1=Lun … 7=Dom) +- **Fecha de inicio** y **fecha de fin** de vigencia +4. **Grabar** para guardar el corte + + Los archivos se guardan en: +- ~/.gradio/data/comerciales/{H}/{M}.com +- ~/.gradio/data/eventos/{H}/{M}.com +- ~/.gradio/data/eventos-espera/{H}/{M}.com + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OQQmAABRAsSd4tIGdjCS/pwGsYQVvImwJtszMXp0BAPAX91pt1fH1BACA164HhYgEO/4GtLAAAAAASUVORK5CYII=) +**Botonera de efectos (gr-botonera)** +La **Botonera** es un tablero de reproducción instantánea de efectos de sonido, cortinas, ID de emisora u otros audios de uso frecuente. +**Organización** +- **5 pestañas** renombrables (doble clic sobre el nombre de la pestaña para renombrar) +- Cada pestaña contiene una **grilla de botones** de colores +- Los botones son configurables individualmente +**Uso** +- **Arrastrar un archivo de audio** sobre un botón vacío → lo asigna a ese botón +- **Clic en un botón** → reproduce el audio asignado; si ya está sonando, lo detiene +- **Clic derecho sobre un botón** → menú para cambiar el color o limpiar el botón +- Los colores se eligen de una paleta de 15 colores predefinidos + + La configuración se guarda automáticamente en ~/.gradio/data/botonera/config.json. + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANElEQVR4nO3OUQmAABBAsSeYxKTXxlomEBOIFfwTYUuwZWa2ag8AgL841uquzq8nAAC8dj05XgYLDGrT0AAAAABJRU5ErkJggg==) +**Visor de programación (gr-visor)** +El **Visor** muestra en una sola pantalla todo lo programado para el día en curso, actualizándose automáticamente cada 60 segundos. +**Panel superior: Pautaje del día** +Tabla con todos los cortes programados, agrupados por hora:minuto y coloreados por tipo: + + | **Color** | **Tipo** | + + |-|-| + + | Verde claro | Comerciales | + + | Teal claro | Eventos | + + | Lavanda | Eventos en espera | + + +**Panel inferior: Parrilla musical** +Muestra la parrilla musical de la hora actual y la siguiente, con indicación especial (amarillo) de las marcas de **Hora**. + + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OQQ2AQBAAsSHhiQMcoWp9ngBsYIEfIWkVdJuZs5oAAPiLe6+O6vp6AgDAa+sBhZgEOcyZTEcAAAAASUVORK5CYII=) +**Buscador de audio (gr-buscador)** +El **Buscador** permite localizar rápidamente cualquier archivo de audio por nombre. +**Uso** +1. Escribir el texto a buscar en el campo superior y presionar **Enter** o el botón **Buscar** +2. Los resultados muestran: **Tema** | **Duración** | **Ruta** +3. **Doble clic** sobre un resultado → agrega el archivo al final de la lista de reproducción del reproductor principal +4. **Arrastrar** un resultado → permite soltarlo en la posición deseada dentro del reproductor +**Estrategia de búsqueda** +El buscador usa primero locate (índice del sistema, instantáneo). Si locate no está disponible, realiza una búsqueda recursiva en las carpetas de la parrilla y las carpetas habituales de música. + + ***Consejo*** *: para mantener el índice de * *locate* * actualizado, ejecutar periódicamente * *sudo updatedb* *.* + + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OMQ2AABAAsSPBCUZfDq7YGVDAgAU2QtIq6DIzW7UHAMBfHGt1V+fXEwAAXrseHCgGBJWaMWkAAAAASUVORK5CYII=) +**Reportería (gr-reportes)** +La **Reportería** genera informes de los audios emitidos, con detalle de duración real al aire y clasificación por tipo de contenido. +**Selección de período** +- Elegir la **fecha de inicio** y **fecha de fin** con los selectores de fecha (clic → calendario emergente) +- El informe cubre todos los días del rango seleccionado +**Filtro de audio** +El campo **Buscar audio** permite filtrar los resultados por nombre de archivo (búsqueda insensible a mayúsculas/minúsculas). +**Columnas del informe** +| | | +|-|-| +| **Columna** | **Descripción** | +| Fecha | Fecha de emisión | +| Hora | Hora exacta de inicio | +| Audio | Nombre del archivo | +| Duración | Segundos reales emitidos | +| | | +| ### Resumen por día | | +| La sección **Resumen por Día** muestra, para cada día del período: | | +| **Columna** | **Descripción** | +| - | - | +| FECHA | Día del resumen | +| TOTAL | Total de segundos emitidos en el día | +| NACIONAL | Segundos de música nacional + porcentaje | +| INTERCULTURAL | Segundos de música intercultural + porcentaje | +| | | +| Las carpetas consideradas como **Nacional** e **Intercultural** se configuran en el diálogo de **Configuración** del reproductor principal. | | +| ### Exportar a PDF | | +| El botón **Exportar PDF** genera un informe completo en formato PDF que incluye: | | + +- **Página 1+**: tabla completa de audios emitidos con logo y nombre de la emisora +- **Última página**: tabla de resumen por día con totales y porcentajes de Nacional e Intercultural + + El PDF se guarda en el directorio de inicio del usuario. + ![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OMQ2AABAAsSPBCUZfDq7YGVDAgAU2QtIq6DIzW7UHAMBfHGt1V+fXEwAAXrseHCgGBJWaMWkAAAAASUVORK5CYII=) +**Editor de playlists (gr-playlist)** +El editor de playlists permite crear y editar archivos .gradio (listas de reproducción guardadas). + + Un archivo .gradio contiene una lista de rutas de audio con sus duraciones en formato tabular. Se puede cargar directamente en el reproductor principal arrastrando el archivo .gradio a la ventana. +**Grabador de audio (gr-record)** +El grabador de audio permite capturar cualquier fuente PulseAudio (entrada de línea, monitor de tarjeta de sonido, micrófono, etc.) y guardarla como archivo MP3. +**Fuente de audio** +Al abrir la herramienta, el menú desplegable lista todas las fuentes detectadas por PulseAudio. El botón ⟳ refresca la lista sin reiniciar. Ejemplos: +- alsa_input.pci-...analog-stereo — entrada de línea de la tarjeta de sonido +- alsa_output.pci-...analog-stereo.monitor — monitor (captura lo que suena por los parlantes) +- Cualquier dispositivo virtual de PulseAudio (Loopback, virtual sink, etc.) +**VU Meter** +Idéntico al del reproductor principal: dos barras de LEDs (L/R), verde 0–55 %, amarillo 55–75 %, rojo 75–100 %. Solo trabaja con grabación activa; al detener, los niveles caen a cero. +**Botón de grabación** +Ubicado en la esquina inferior derecha de la ventana: +- **Inactivo** — ícono gris (rec-off) +- **Grabando** — ícono rojo con fondo rojo tenue (rec-on) +Al iniciar la grabación, la barra inferior muestra el nombre del archivo generado y un contador HH:MM:SS. Al detener, el pipeline cierra el archivo correctamente antes de liberar recursos. +**Archivos generados** +Se guardan en ~/GR-grabaciones/ (creada automáticamente) con formato: + + GRadio-rec-YYYY-MM-DD-HH-MM.mp3 + + Encoder: **lamemp3enc** a 192 kbps (fallback: avenc_mp3 de ffmpeg). +**Pipeline GStreamer** +pulsesrc → audioconvert → audioresample → level (50 ms) → lamemp3enc → filesink +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OYQ1AABSAwY8JIIKoL4Z8Eoiggn9mu0twy8wc1RkAAH9xbdVa7V9PAAB47X4A9DIEIm50tIwAAAAASUVORK5CYII=) +**Estructura de archivos de datos** +Todos los datos del sistema se almacenan bajo ~/.gradio/: +~/.gradio/ + ├── data/ + │ ├── parrilla/ # Parrilla musical por día y hora + │ │ ├── 1/ # Lunes + │ │ │ ├── 0-1.mus # Parrilla hora 0 (00:00 – 01:00) + │ │ │ ├── 0-1.pisador # Carpeta de pisadores para esa hora (opcional) + │ │ │ ├── 1-2.mus # Parrilla hora 1 (01:00 – 02:00) + │ │ │ ├── 1-2.pisador # Carpeta de pisadores para esa hora (opcional) + │ │ │ └── ... + │ │ ├── 2/ # Martes + │ │ └── ... # (hasta 7 = Domingo) + │ ├── comerciales/ # Pautaje de comerciales + │ │ └── {H}/ # Por hora (0-23) + │ │ └── {M}.com # Por minuto (0-59) + │ ├── eventos/ # Pautaje de eventos + │ │ └── {H}/{M}.com + │ ├── eventos-espera/ # Eventos en espera + │ │ └── {H}/{M}.com + │ ├── panel/ + │ │ └── Time/ # Audios de locución de hora (ver sección Hora) + │ ├── botonera/ + │ │ └── config.json # Configuración de la botonera + │ └── tmp/ # Archivos de comunicación entre procesos + │ ├── playlist4 # Cola de música activa + │ ├── comercialeslist4 # Cola de comerciales activa + │ ├── eventos-esperalist # Cola de eventos en espera + │ ├── gradio.config # Configuración principal + │ ├── upvol # Nivel de volumen normal (0-100) + │ └── downvol # Nivel de volumen duck (0-100) + └── reportes/ # Logs de audios emitidos + ├── GR6-parrilla-YYYY-MM-DD.txt # Registro diario de emisión + └── {carpeta}/.historico.mus # Historial por carpeta (antirepetición) + +**Formato de archivos de parrilla (.mus)** +Cada línea puede ser: +/home/usuario/Musica/Variados/* # Archivo aleatorio de carpeta + /home/usuario/Musica/Temas/tema.mp3 # Archivo específico + Hora # Marca de locución de hora + +**Formato de archivos de pautaje (.com)** +/ruta/audio.mp3|1234567|20260101|20261231 + +**Formato de cola de reproducción (playlist4)** +/ruta/al/archivo.mp3[TAB]00:03:45.000 + +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OMQ2AABAAsSNhYEUALhD4K0LxgQU2QtIq6DIzR3UFAMBf3Gu1VefXEwAAXtsfSp4DXi4fyswAAAAASUVORK5CYII=) +**Clientes remotos** +G Radio Player incluye dos clientes remotos que permiten controlar el servidor desde otra máquina en la misma red (protocolo JSON/TCP en el puerto 7777). +**Cliente de escritorio (radio-player-client)** +Aplicación GTK4 para Linux con las siguientes pantallas: +| | | +|-|-| +| **Pantalla** | **Función** | +| **▶ Player** | Controles de reproducción, VU, decks A/B, lista de cola | +| **📅 Pautaje** | Editar la programación de comerciales y eventos remotamente | +| **🎵 Parrilla** | Ver y editar la parrilla musical del servidor | +| **🎛 Botonera** | Acceder a la botonera de efectos del servidor | +| **📢 Comercios** | Ver y gestionar las colas activas de comerciales y eventos | +| **📊 Reportes** | Consultar los reportes de emisión | + +**Cliente Android (GRadio Client)** +Aplicación para Android con interfaz Jetpack Compose. Requiere ingresar la IP y puerto del servidor al conectar. Pantallas disponibles: +| | | +|-|-| +| **Pantalla** | **Función** | +| **Player** | Estado de reproducción, controles básicos, volumen | +| **Búsqueda** | Buscar audios en el servidor y agregarlos a la cola | +| **Parrilla** | Ver y editar la parrilla musical; botones inline ▲ ▼ 🗑 por ítem | +| **Botonera** | Reproducir efectos desde la botonera del servidor | +| **Pautaje** | Editar la programación de comerciales | +| **Comercios** | Ver colas activas de comerciales/eventos con borrado individual | +| **Reportes** | Consultar reportes de emisión | + +La conexión se establece ingresando IP:7777 en la pantalla de inicio. El cliente se reconecta automáticamente si pierde la conexión. +*G Radio Player v-0.3.6 — Desarrollado por Charles Escobar* diff --git a/README.md b/README.md new file mode 100644 index 0000000..059d49c --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# G Radio Player + +Reproductor de radio profesional — GTK4 + GStreamer + Rust + +## Binarios + +| Binario | Descripción | +|---|---| +| `radio-player` | Interfaz principal GTK4 | +| `comercial-scheduler` | Carga comerciales y eventos al segundo :58 de cada minuto | +| `playlist-refill` | Mantiene la cola de música con 14 temas leyendo de la parrilla | + +## Compilar + +```bash +./build.sh +``` + +## Ejecutar + +```bash +./run.sh # inicia los tres procesos juntos +``` + +O por separado (para depuración): + +```bash +RUST_LOG=info ./target/release/radio-player +./target/release/comercial-scheduler +./target/release/playlist-refill +``` + +## Estructura de archivos + +``` +$HOME/.gradio/data/ +├── tmp/ +│ ├── playlist4 # cola de música (ruta TAB MM:SS.mmm) +│ ├── comercialeslist4 # cola de comerciales +│ ├── eventos-esperalist # eventos post-comerciales +│ └── eventoslist # eventos emergentes +├── comerciales/ +│ └── {HH}/{MM}.com # pautas por hora:minuto +│ formato: ruta|dias|YYYYMMDD_ini|YYYYMMDD_fin +│ dias: "12345" = Lun-Vie, "67" = Sáb-Dom +├── eventos-espera/ +│ └── {HH}/{MM}.com # mismo formato +├── eventos/ +│ └── {HH}/{MM}.com # mismo formato +├── parrilla/ +│ └── {DOW}/{HH}-{HH+1}.mus # archivos de música por franja +│ formato: ruta/al/archivo.mp3 o /ruta/carpeta/* +└── reporte/ + ├── GR6-parrilla-YYYY-MM-DD.txt + ├── GR6-comercial-YYYY-MM-DD.txt + └── GR6-evento-YYYY-MM-DD.txt + +$HOME/.gradio/data/mix # segundos de crossfade +$HOME/.gradio/data/tmp/upvol # volumen normal 0-100 +$HOME/.gradio/data/tmp/downvol # volumen duck 0-100 +$HOME/.gradio/data/panel/Time/ # HRS{HH}.mp3, HRS{HH}_O.mp3, MIN{MM}.mp3 + +$HOME/G Radio/inicio-espacio-pub/ # jingles inicio tanda +$HOME/G Radio/fin-espacio-pub/ # jingles fin tanda +``` + +## Formato de pautas (.com) + +``` +/ruta/al/audio.mp3|12345|20250101|20251231 +/otra/pista.wav|67|20250601|20250630 +``` + +- Campo 1: ruta del archivo +- Campo 2: máscara de días (1=Lun … 7=Dom) +- Campo 3: fecha inicio YYYYMMDD +- Campo 4: fecha fin YYYYMMDD + +## Formato de parrilla (.mus) + +``` +/ruta/carpeta/* # elige aleatoriamente de la carpeta +/ruta/exacta/tema.mp3 # archivo concreto +``` + +## Dependencias del sistema + +- GTK4 ≥ 4.6 +- GStreamer ≥ 1.20 con plugins: gst-plugins-base, gst-plugins-good, gst-plugins-ugly +- ffprobe (paquete ffmpeg) — usado por playlist-refill para leer duraciones + +## Variables de entorno + +- `GRADIO_RELAY_URL` — URL del servidor de relay WebSocket para acceso remoto vía + internet (default `wss://relay.example.com/ws`). Para autohospedaje, definirla antes + de lanzar `radio-player`. + +## Licencia + +Distribuido bajo los términos de la **GNU General Public License v3.0 o posterior** +(GPL-3.0-or-later). Ver el archivo [`LICENSE`](./LICENSE). + +Este programa enlaza dinámicamente con **GTK4** y **GStreamer**, ambos publicados +bajo LGPL-2.1+ y compatibles con GPL-3.0. diff --git a/REPARAR-DPKG.md b/REPARAR-DPKG.md new file mode 100644 index 0000000..6493131 --- /dev/null +++ b/REPARAR-DPKG.md @@ -0,0 +1,96 @@ +# Reparar dpkg roto por G Radio Player + +## Actualizar desde 0.4.8 o anterior a 0.4.9+ + +Si tienes una versión anterior instalada, **no ejecutes `dpkg -i` directamente** — el `prerm` del paquete instalado corre primero y puede matar a `dpkg`. + +Usa el script incluido en el ZIP, que parchea automáticamente antes de instalar: + +```bash +sudo bash instalar.sh +``` + +O aplica el parche manual de una línea antes de instalar: + +```bash +sudo bash -c 'printf "#!/bin/bash\nexit 0\n" > /var/lib/dpkg/info/gradio-player.prerm' +sudo dpkg -i gradio-player_0.4.9_amd64.deb # o arm64 / arm64_bookworm +``` + +--- + +## Mi dpkg ya está roto (no puedo instalar ni actualizar nada) + +### Síntoma + +``` +E: se interrumpió la ejecución de dpkg, debe ejecutar manualmente + «sudo dpkg --configure -a» para corregir el problema +``` + +O: + +``` +dpkg: error al procesar el paquete gradio-player (--configure): + El paquete está en un estado grave de inconsistencia - debe reinstalarlo + antes de intentar su configuración. +``` + +### Reparación rápida + +```bash +# 1. Parchear el prerm roto +sudo bash -c 'printf "#!/bin/bash\nexit 0\n" > /var/lib/dpkg/info/gradio-player.prerm' + +# 2. Reinstalar 0.4.9 (ya tiene prerm correcto) +sudo dpkg -i gradio-player_0.4.9_amd64.deb # o arm64 / arm64_bookworm +``` + +Si aun así dpkg reporta inconsistencia, forzar la eliminación primero: + +```bash +sudo bash -c 'printf "#!/bin/bash\nexit 0\n" > /var/lib/dpkg/info/gradio-player.prerm' +sudo dpkg --remove --force-remove-reinstreq gradio-player +sudo dpkg --configure -a +sudo dpkg -i gradio-player_0.4.9_amd64.deb +``` + +--- + +## Causa raíz + +El script `prerm` de versiones anteriores usaba `pkill -f "radio-player"`. El flag `-f` busca en la línea de comando **completa** de todos los procesos. La cadena `"radio-player"` es subcadena de `"gradio-player"`, por lo que el patrón coincide con: + +- El propio script de mantenimiento: `/var/lib/dpkg/info/gradio-player.prerm` +- La invocación de dpkg: `dpkg -i gradio-player_0.4.9_amd64.deb` + +Al ejecutar, el script se mata a sí mismo y a dpkg, dejando el gestor de paquetes en estado inconsistente. + +**Desde 0.4.9**: el `prerm` es `exit 0`. No hay kill de procesos. Los ejecutables en uso se reemplazan de forma segura en disco (Linux mantiene el inode anterior abierto hasta que el proceso cierre). + +--- + +## Limpieza manual (si dpkg no removió los archivos) + +```bash +sudo rm -f /usr/local/bin/{radio-player,comercial-scheduler,playlist-refill} +sudo rm -f /usr/local/bin/{gradio.sh,gr-buscador,gr-playlist} +sudo rm -f /usr/local/bin/{gr-pautaje,gr-parrilla,gr-botonera,gr-visor,gr-reportes,gr-record} +sudo rm -rf /usr/local/share/radio-player +sudo rm -f /usr/share/applications/gradio-player.desktop +sudo rm -f /usr/share/icons/hicolor/48x48/apps/gradio-player.png +sudo gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null +sudo update-desktop-database /usr/share/applications 2>/dev/null +``` + +Los datos del usuario en `~/.gradio/` no se eliminan (parrilla, comerciales, configuración). + +--- + +## Historial del bug + +| Versión | Estado prerm | Notas | +|---------|-------------|-------| +| 0.3.x – 0.4.7 | `pkill -f` roto | Todos los .deb publicados afectados | +| 0.4.8 | `pkill -x` (mejorado) | Sigue siendo un kill — al actualizar a 0.4.9 corre el prerm de 0.4.8 instalado, que puede matar dpkg en algunos entornos | +| **0.4.9+** | `exit 0` | **Fix definitivo** — no hay kill de procesos | diff --git a/assets/24-Lopp-off.png b/assets/24-Lopp-off.png new file mode 100755 index 0000000..7c785c2 Binary files /dev/null and b/assets/24-Lopp-off.png differ diff --git a/assets/24-Lopp-on.png b/assets/24-Lopp-on.png new file mode 100755 index 0000000..6c77434 Binary files /dev/null and b/assets/24-Lopp-on.png differ diff --git a/assets/GR-Off.png b/assets/GR-Off.png new file mode 100644 index 0000000..7156579 Binary files /dev/null and b/assets/GR-Off.png differ diff --git a/assets/GR-On.png b/assets/GR-On.png new file mode 100644 index 0000000..016345c Binary files /dev/null and b/assets/GR-On.png differ diff --git a/assets/Time/HRS00.mp3 b/assets/Time/HRS00.mp3 new file mode 100644 index 0000000..74fd4b2 Binary files /dev/null and b/assets/Time/HRS00.mp3 differ diff --git a/assets/Time/HRS00_O.mp3 b/assets/Time/HRS00_O.mp3 new file mode 100644 index 0000000..8f629ab Binary files /dev/null and b/assets/Time/HRS00_O.mp3 differ diff --git a/assets/Time/HRS01.mp3 b/assets/Time/HRS01.mp3 new file mode 100644 index 0000000..3c9a89e Binary files /dev/null and b/assets/Time/HRS01.mp3 differ diff --git a/assets/Time/HRS01_O.mp3 b/assets/Time/HRS01_O.mp3 new file mode 100644 index 0000000..b40e485 Binary files /dev/null and b/assets/Time/HRS01_O.mp3 differ diff --git a/assets/Time/HRS02.mp3 b/assets/Time/HRS02.mp3 new file mode 100644 index 0000000..1b1f206 Binary files /dev/null and b/assets/Time/HRS02.mp3 differ diff --git a/assets/Time/HRS02_O.mp3 b/assets/Time/HRS02_O.mp3 new file mode 100644 index 0000000..d1aca71 Binary files /dev/null and b/assets/Time/HRS02_O.mp3 differ diff --git a/assets/Time/HRS03.mp3 b/assets/Time/HRS03.mp3 new file mode 100644 index 0000000..05e8062 Binary files /dev/null and b/assets/Time/HRS03.mp3 differ diff --git a/assets/Time/HRS03_O.mp3 b/assets/Time/HRS03_O.mp3 new file mode 100644 index 0000000..c684455 Binary files /dev/null and b/assets/Time/HRS03_O.mp3 differ diff --git a/assets/Time/HRS04.mp3 b/assets/Time/HRS04.mp3 new file mode 100644 index 0000000..d57bb59 Binary files /dev/null and b/assets/Time/HRS04.mp3 differ diff --git a/assets/Time/HRS04_O.mp3 b/assets/Time/HRS04_O.mp3 new file mode 100644 index 0000000..1929079 Binary files /dev/null and b/assets/Time/HRS04_O.mp3 differ diff --git a/assets/Time/HRS05.mp3 b/assets/Time/HRS05.mp3 new file mode 100644 index 0000000..dd950ed Binary files /dev/null and b/assets/Time/HRS05.mp3 differ diff --git a/assets/Time/HRS05_O.mp3 b/assets/Time/HRS05_O.mp3 new file mode 100644 index 0000000..677dc71 Binary files /dev/null and b/assets/Time/HRS05_O.mp3 differ diff --git a/assets/Time/HRS06.mp3 b/assets/Time/HRS06.mp3 new file mode 100644 index 0000000..c1ce97e Binary files /dev/null and b/assets/Time/HRS06.mp3 differ diff --git a/assets/Time/HRS06_O.mp3 b/assets/Time/HRS06_O.mp3 new file mode 100644 index 0000000..e31edde Binary files /dev/null and b/assets/Time/HRS06_O.mp3 differ diff --git a/assets/Time/HRS07.mp3 b/assets/Time/HRS07.mp3 new file mode 100644 index 0000000..021638c Binary files /dev/null and b/assets/Time/HRS07.mp3 differ diff --git a/assets/Time/HRS07_O.mp3 b/assets/Time/HRS07_O.mp3 new file mode 100644 index 0000000..4493a51 Binary files /dev/null and b/assets/Time/HRS07_O.mp3 differ diff --git a/assets/Time/HRS08.mp3 b/assets/Time/HRS08.mp3 new file mode 100644 index 0000000..f859899 Binary files /dev/null and b/assets/Time/HRS08.mp3 differ diff --git a/assets/Time/HRS08_O.mp3 b/assets/Time/HRS08_O.mp3 new file mode 100644 index 0000000..feaec5d Binary files /dev/null and b/assets/Time/HRS08_O.mp3 differ diff --git a/assets/Time/HRS09.mp3 b/assets/Time/HRS09.mp3 new file mode 100644 index 0000000..1d78a55 Binary files /dev/null and b/assets/Time/HRS09.mp3 differ diff --git a/assets/Time/HRS09_O.mp3 b/assets/Time/HRS09_O.mp3 new file mode 100644 index 0000000..94da832 Binary files /dev/null and b/assets/Time/HRS09_O.mp3 differ diff --git a/assets/Time/HRS10.mp3 b/assets/Time/HRS10.mp3 new file mode 100644 index 0000000..585bfad Binary files /dev/null and b/assets/Time/HRS10.mp3 differ diff --git a/assets/Time/HRS10_O.mp3 b/assets/Time/HRS10_O.mp3 new file mode 100644 index 0000000..d78eff3 Binary files /dev/null and b/assets/Time/HRS10_O.mp3 differ diff --git a/assets/Time/HRS11.mp3 b/assets/Time/HRS11.mp3 new file mode 100644 index 0000000..018d353 Binary files /dev/null and b/assets/Time/HRS11.mp3 differ diff --git a/assets/Time/HRS11_O.mp3 b/assets/Time/HRS11_O.mp3 new file mode 100644 index 0000000..6f0e3a3 Binary files /dev/null and b/assets/Time/HRS11_O.mp3 differ diff --git a/assets/Time/HRS12.mp3 b/assets/Time/HRS12.mp3 new file mode 100644 index 0000000..304623e Binary files /dev/null and b/assets/Time/HRS12.mp3 differ diff --git a/assets/Time/HRS12_O.mp3 b/assets/Time/HRS12_O.mp3 new file mode 100644 index 0000000..2554982 Binary files /dev/null and b/assets/Time/HRS12_O.mp3 differ diff --git a/assets/Time/HRS13.mp3 b/assets/Time/HRS13.mp3 new file mode 100644 index 0000000..aac0813 Binary files /dev/null and b/assets/Time/HRS13.mp3 differ diff --git a/assets/Time/HRS13_O.mp3 b/assets/Time/HRS13_O.mp3 new file mode 100644 index 0000000..d25e38b Binary files /dev/null and b/assets/Time/HRS13_O.mp3 differ diff --git a/assets/Time/HRS14.mp3 b/assets/Time/HRS14.mp3 new file mode 100644 index 0000000..a8d8779 Binary files /dev/null and b/assets/Time/HRS14.mp3 differ diff --git a/assets/Time/HRS14_O.mp3 b/assets/Time/HRS14_O.mp3 new file mode 100644 index 0000000..08dddff Binary files /dev/null and b/assets/Time/HRS14_O.mp3 differ diff --git a/assets/Time/HRS15.mp3 b/assets/Time/HRS15.mp3 new file mode 100644 index 0000000..7429411 Binary files /dev/null and b/assets/Time/HRS15.mp3 differ diff --git a/assets/Time/HRS15_O.mp3 b/assets/Time/HRS15_O.mp3 new file mode 100644 index 0000000..a70d82d Binary files /dev/null and b/assets/Time/HRS15_O.mp3 differ diff --git a/assets/Time/HRS16.mp3 b/assets/Time/HRS16.mp3 new file mode 100644 index 0000000..4cb1976 Binary files /dev/null and b/assets/Time/HRS16.mp3 differ diff --git a/assets/Time/HRS16_O.mp3 b/assets/Time/HRS16_O.mp3 new file mode 100644 index 0000000..177cb1b Binary files /dev/null and b/assets/Time/HRS16_O.mp3 differ diff --git a/assets/Time/HRS17.mp3 b/assets/Time/HRS17.mp3 new file mode 100644 index 0000000..a7033f2 Binary files /dev/null and b/assets/Time/HRS17.mp3 differ diff --git a/assets/Time/HRS17_O.mp3 b/assets/Time/HRS17_O.mp3 new file mode 100644 index 0000000..2572e77 Binary files /dev/null and b/assets/Time/HRS17_O.mp3 differ diff --git a/assets/Time/HRS18.mp3 b/assets/Time/HRS18.mp3 new file mode 100644 index 0000000..0d09cb9 Binary files /dev/null and b/assets/Time/HRS18.mp3 differ diff --git a/assets/Time/HRS18_O.mp3 b/assets/Time/HRS18_O.mp3 new file mode 100644 index 0000000..5f6c891 Binary files /dev/null and b/assets/Time/HRS18_O.mp3 differ diff --git a/assets/Time/HRS19.mp3 b/assets/Time/HRS19.mp3 new file mode 100644 index 0000000..1c85283 Binary files /dev/null and b/assets/Time/HRS19.mp3 differ diff --git a/assets/Time/HRS19_O.mp3 b/assets/Time/HRS19_O.mp3 new file mode 100644 index 0000000..0852087 Binary files /dev/null and b/assets/Time/HRS19_O.mp3 differ diff --git a/assets/Time/HRS20.mp3 b/assets/Time/HRS20.mp3 new file mode 100644 index 0000000..0d2591b Binary files /dev/null and b/assets/Time/HRS20.mp3 differ diff --git a/assets/Time/HRS20_O.mp3 b/assets/Time/HRS20_O.mp3 new file mode 100644 index 0000000..ffc0abe Binary files /dev/null and b/assets/Time/HRS20_O.mp3 differ diff --git a/assets/Time/HRS21.mp3 b/assets/Time/HRS21.mp3 new file mode 100644 index 0000000..bce325b Binary files /dev/null and b/assets/Time/HRS21.mp3 differ diff --git a/assets/Time/HRS21_O.mp3 b/assets/Time/HRS21_O.mp3 new file mode 100644 index 0000000..f2dbce4 Binary files /dev/null and b/assets/Time/HRS21_O.mp3 differ diff --git a/assets/Time/HRS22.mp3 b/assets/Time/HRS22.mp3 new file mode 100644 index 0000000..a625cb1 Binary files /dev/null and b/assets/Time/HRS22.mp3 differ diff --git a/assets/Time/HRS22_O.mp3 b/assets/Time/HRS22_O.mp3 new file mode 100644 index 0000000..eec6add Binary files /dev/null and b/assets/Time/HRS22_O.mp3 differ diff --git a/assets/Time/HRS23.mp3 b/assets/Time/HRS23.mp3 new file mode 100644 index 0000000..2420712 Binary files /dev/null and b/assets/Time/HRS23.mp3 differ diff --git a/assets/Time/HRS23_O.mp3 b/assets/Time/HRS23_O.mp3 new file mode 100644 index 0000000..4806248 Binary files /dev/null and b/assets/Time/HRS23_O.mp3 differ diff --git a/assets/Time/MIN01.mp3 b/assets/Time/MIN01.mp3 new file mode 100644 index 0000000..905ee9e Binary files /dev/null and b/assets/Time/MIN01.mp3 differ diff --git a/assets/Time/MIN02.mp3 b/assets/Time/MIN02.mp3 new file mode 100644 index 0000000..17b2cf6 Binary files /dev/null and b/assets/Time/MIN02.mp3 differ diff --git a/assets/Time/MIN03.mp3 b/assets/Time/MIN03.mp3 new file mode 100644 index 0000000..c3f565f Binary files /dev/null and b/assets/Time/MIN03.mp3 differ diff --git a/assets/Time/MIN04.mp3 b/assets/Time/MIN04.mp3 new file mode 100644 index 0000000..232fe97 Binary files /dev/null and b/assets/Time/MIN04.mp3 differ diff --git a/assets/Time/MIN05.mp3 b/assets/Time/MIN05.mp3 new file mode 100644 index 0000000..5e8789c Binary files /dev/null and b/assets/Time/MIN05.mp3 differ diff --git a/assets/Time/MIN06.mp3 b/assets/Time/MIN06.mp3 new file mode 100644 index 0000000..60c2dd6 Binary files /dev/null and b/assets/Time/MIN06.mp3 differ diff --git a/assets/Time/MIN07.mp3 b/assets/Time/MIN07.mp3 new file mode 100644 index 0000000..3a608e4 Binary files /dev/null and b/assets/Time/MIN07.mp3 differ diff --git a/assets/Time/MIN08.mp3 b/assets/Time/MIN08.mp3 new file mode 100644 index 0000000..c927ace Binary files /dev/null and b/assets/Time/MIN08.mp3 differ diff --git a/assets/Time/MIN09.mp3 b/assets/Time/MIN09.mp3 new file mode 100644 index 0000000..bd38280 Binary files /dev/null and b/assets/Time/MIN09.mp3 differ diff --git a/assets/Time/MIN10.mp3 b/assets/Time/MIN10.mp3 new file mode 100644 index 0000000..489759b Binary files /dev/null and b/assets/Time/MIN10.mp3 differ diff --git a/assets/Time/MIN11.mp3 b/assets/Time/MIN11.mp3 new file mode 100644 index 0000000..23fe829 Binary files /dev/null and b/assets/Time/MIN11.mp3 differ diff --git a/assets/Time/MIN12.mp3 b/assets/Time/MIN12.mp3 new file mode 100644 index 0000000..35d9712 Binary files /dev/null and b/assets/Time/MIN12.mp3 differ diff --git a/assets/Time/MIN13.mp3 b/assets/Time/MIN13.mp3 new file mode 100644 index 0000000..2b11228 Binary files /dev/null and b/assets/Time/MIN13.mp3 differ diff --git a/assets/Time/MIN14.mp3 b/assets/Time/MIN14.mp3 new file mode 100644 index 0000000..70cbe5f Binary files /dev/null and b/assets/Time/MIN14.mp3 differ diff --git a/assets/Time/MIN15.mp3 b/assets/Time/MIN15.mp3 new file mode 100644 index 0000000..7950304 Binary files /dev/null and b/assets/Time/MIN15.mp3 differ diff --git a/assets/Time/MIN16.mp3 b/assets/Time/MIN16.mp3 new file mode 100644 index 0000000..eb5ec76 Binary files /dev/null and b/assets/Time/MIN16.mp3 differ diff --git a/assets/Time/MIN17.mp3 b/assets/Time/MIN17.mp3 new file mode 100644 index 0000000..58054cf Binary files /dev/null and b/assets/Time/MIN17.mp3 differ diff --git a/assets/Time/MIN18.mp3 b/assets/Time/MIN18.mp3 new file mode 100644 index 0000000..9933b14 Binary files /dev/null and b/assets/Time/MIN18.mp3 differ diff --git a/assets/Time/MIN19.mp3 b/assets/Time/MIN19.mp3 new file mode 100644 index 0000000..a56d464 Binary files /dev/null and b/assets/Time/MIN19.mp3 differ diff --git a/assets/Time/MIN20.mp3 b/assets/Time/MIN20.mp3 new file mode 100644 index 0000000..b4536de Binary files /dev/null and b/assets/Time/MIN20.mp3 differ diff --git a/assets/Time/MIN21.mp3 b/assets/Time/MIN21.mp3 new file mode 100644 index 0000000..63a5aaa Binary files /dev/null and b/assets/Time/MIN21.mp3 differ diff --git a/assets/Time/MIN22.mp3 b/assets/Time/MIN22.mp3 new file mode 100644 index 0000000..4c0cfd3 Binary files /dev/null and b/assets/Time/MIN22.mp3 differ diff --git a/assets/Time/MIN23.mp3 b/assets/Time/MIN23.mp3 new file mode 100644 index 0000000..8b5610c Binary files /dev/null and b/assets/Time/MIN23.mp3 differ diff --git a/assets/Time/MIN24.mp3 b/assets/Time/MIN24.mp3 new file mode 100644 index 0000000..a0624f9 Binary files /dev/null and b/assets/Time/MIN24.mp3 differ diff --git a/assets/Time/MIN25.mp3 b/assets/Time/MIN25.mp3 new file mode 100644 index 0000000..c2a55cb Binary files /dev/null and b/assets/Time/MIN25.mp3 differ diff --git a/assets/Time/MIN26.mp3 b/assets/Time/MIN26.mp3 new file mode 100644 index 0000000..5d0772b Binary files /dev/null and b/assets/Time/MIN26.mp3 differ diff --git a/assets/Time/MIN27.mp3 b/assets/Time/MIN27.mp3 new file mode 100644 index 0000000..e5af83d Binary files /dev/null and b/assets/Time/MIN27.mp3 differ diff --git a/assets/Time/MIN28.mp3 b/assets/Time/MIN28.mp3 new file mode 100644 index 0000000..090ca82 Binary files /dev/null and b/assets/Time/MIN28.mp3 differ diff --git a/assets/Time/MIN29.mp3 b/assets/Time/MIN29.mp3 new file mode 100644 index 0000000..f833c15 Binary files /dev/null and b/assets/Time/MIN29.mp3 differ diff --git a/assets/Time/MIN30.mp3 b/assets/Time/MIN30.mp3 new file mode 100644 index 0000000..95f0fe4 Binary files /dev/null and b/assets/Time/MIN30.mp3 differ diff --git a/assets/Time/MIN31.mp3 b/assets/Time/MIN31.mp3 new file mode 100644 index 0000000..c9be787 Binary files /dev/null and b/assets/Time/MIN31.mp3 differ diff --git a/assets/Time/MIN32.mp3 b/assets/Time/MIN32.mp3 new file mode 100644 index 0000000..43bfd00 Binary files /dev/null and b/assets/Time/MIN32.mp3 differ diff --git a/assets/Time/MIN33.mp3 b/assets/Time/MIN33.mp3 new file mode 100644 index 0000000..4d2fca3 Binary files /dev/null and b/assets/Time/MIN33.mp3 differ diff --git a/assets/Time/MIN34.mp3 b/assets/Time/MIN34.mp3 new file mode 100644 index 0000000..6dea11a Binary files /dev/null and b/assets/Time/MIN34.mp3 differ diff --git a/assets/Time/MIN35.mp3 b/assets/Time/MIN35.mp3 new file mode 100644 index 0000000..d10bce9 Binary files /dev/null and b/assets/Time/MIN35.mp3 differ diff --git a/assets/Time/MIN36.mp3 b/assets/Time/MIN36.mp3 new file mode 100644 index 0000000..323d166 Binary files /dev/null and b/assets/Time/MIN36.mp3 differ diff --git a/assets/Time/MIN37.mp3 b/assets/Time/MIN37.mp3 new file mode 100644 index 0000000..541e934 Binary files /dev/null and b/assets/Time/MIN37.mp3 differ diff --git a/assets/Time/MIN38.mp3 b/assets/Time/MIN38.mp3 new file mode 100644 index 0000000..e936884 Binary files /dev/null and b/assets/Time/MIN38.mp3 differ diff --git a/assets/Time/MIN39.mp3 b/assets/Time/MIN39.mp3 new file mode 100644 index 0000000..4da6640 Binary files /dev/null and b/assets/Time/MIN39.mp3 differ diff --git a/assets/Time/MIN40.mp3 b/assets/Time/MIN40.mp3 new file mode 100644 index 0000000..5058dbd Binary files /dev/null and b/assets/Time/MIN40.mp3 differ diff --git a/assets/Time/MIN41.mp3 b/assets/Time/MIN41.mp3 new file mode 100644 index 0000000..2112d58 Binary files /dev/null and b/assets/Time/MIN41.mp3 differ diff --git a/assets/Time/MIN42.mp3 b/assets/Time/MIN42.mp3 new file mode 100644 index 0000000..01d6dd5 Binary files /dev/null and b/assets/Time/MIN42.mp3 differ diff --git a/assets/Time/MIN43.mp3 b/assets/Time/MIN43.mp3 new file mode 100644 index 0000000..8c86b9b Binary files /dev/null and b/assets/Time/MIN43.mp3 differ diff --git a/assets/Time/MIN44.mp3 b/assets/Time/MIN44.mp3 new file mode 100644 index 0000000..582b31e Binary files /dev/null and b/assets/Time/MIN44.mp3 differ diff --git a/assets/Time/MIN45.mp3 b/assets/Time/MIN45.mp3 new file mode 100644 index 0000000..6dae7aa Binary files /dev/null and b/assets/Time/MIN45.mp3 differ diff --git a/assets/Time/MIN46.mp3 b/assets/Time/MIN46.mp3 new file mode 100644 index 0000000..4a99aa4 Binary files /dev/null and b/assets/Time/MIN46.mp3 differ diff --git a/assets/Time/MIN47.mp3 b/assets/Time/MIN47.mp3 new file mode 100644 index 0000000..0dd9781 Binary files /dev/null and b/assets/Time/MIN47.mp3 differ diff --git a/assets/Time/MIN48.mp3 b/assets/Time/MIN48.mp3 new file mode 100644 index 0000000..cf98c75 Binary files /dev/null and b/assets/Time/MIN48.mp3 differ diff --git a/assets/Time/MIN49.mp3 b/assets/Time/MIN49.mp3 new file mode 100644 index 0000000..6c67889 Binary files /dev/null and b/assets/Time/MIN49.mp3 differ diff --git a/assets/Time/MIN50.mp3 b/assets/Time/MIN50.mp3 new file mode 100644 index 0000000..9be8cb2 Binary files /dev/null and b/assets/Time/MIN50.mp3 differ diff --git a/assets/Time/MIN51.mp3 b/assets/Time/MIN51.mp3 new file mode 100644 index 0000000..ddcc916 Binary files /dev/null and b/assets/Time/MIN51.mp3 differ diff --git a/assets/Time/MIN52.mp3 b/assets/Time/MIN52.mp3 new file mode 100644 index 0000000..2d61907 Binary files /dev/null and b/assets/Time/MIN52.mp3 differ diff --git a/assets/Time/MIN53.mp3 b/assets/Time/MIN53.mp3 new file mode 100644 index 0000000..937eb20 Binary files /dev/null and b/assets/Time/MIN53.mp3 differ diff --git a/assets/Time/MIN54.mp3 b/assets/Time/MIN54.mp3 new file mode 100644 index 0000000..a66d35d Binary files /dev/null and b/assets/Time/MIN54.mp3 differ diff --git a/assets/Time/MIN55.mp3 b/assets/Time/MIN55.mp3 new file mode 100644 index 0000000..71b4e8e Binary files /dev/null and b/assets/Time/MIN55.mp3 differ diff --git a/assets/Time/MIN56.mp3 b/assets/Time/MIN56.mp3 new file mode 100644 index 0000000..93305d0 Binary files /dev/null and b/assets/Time/MIN56.mp3 differ diff --git a/assets/Time/MIN57.mp3 b/assets/Time/MIN57.mp3 new file mode 100644 index 0000000..a16c67c Binary files /dev/null and b/assets/Time/MIN57.mp3 differ diff --git a/assets/Time/MIN58.mp3 b/assets/Time/MIN58.mp3 new file mode 100644 index 0000000..635010d Binary files /dev/null and b/assets/Time/MIN58.mp3 differ diff --git a/assets/Time/MIN59.mp3 b/assets/Time/MIN59.mp3 new file mode 100644 index 0000000..509f8cd Binary files /dev/null and b/assets/Time/MIN59.mp3 differ diff --git a/assets/Time/tono-hora.ogg b/assets/Time/tono-hora.ogg new file mode 100755 index 0000000..8da101e Binary files /dev/null and b/assets/Time/tono-hora.ogg differ diff --git a/assets/Trash.svg b/assets/Trash.svg new file mode 100644 index 0000000..0caf78a --- /dev/null +++ b/assets/Trash.svg @@ -0,0 +1,137 @@ + + + + diff --git a/assets/VUmetro.svg b/assets/VUmetro.svg new file mode 100644 index 0000000..7aa2643 --- /dev/null +++ b/assets/VUmetro.svg @@ -0,0 +1,165 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VU + + diff --git a/assets/abajo.png b/assets/abajo.png new file mode 100644 index 0000000..a55cfb5 Binary files /dev/null and b/assets/abajo.png differ diff --git a/assets/arriba.png b/assets/arriba.png new file mode 100644 index 0000000..0164490 Binary files /dev/null and b/assets/arriba.png differ diff --git a/assets/botonera48x48.png b/assets/botonera48x48.png new file mode 100644 index 0000000..93ca854 Binary files /dev/null and b/assets/botonera48x48.png differ diff --git a/assets/busqueda.png b/assets/busqueda.png new file mode 100644 index 0000000..b009d07 Binary files /dev/null and b/assets/busqueda.png differ diff --git a/assets/config48x48.png b/assets/config48x48.png new file mode 100644 index 0000000..c302110 Binary files /dev/null and b/assets/config48x48.png differ diff --git a/assets/detener-siguiente.png b/assets/detener-siguiente.png new file mode 100755 index 0000000..2503d7b Binary files /dev/null and b/assets/detener-siguiente.png differ diff --git a/assets/down.png b/assets/down.png new file mode 100644 index 0000000..ef08d30 Binary files /dev/null and b/assets/down.png differ diff --git a/assets/fadeout-off.png b/assets/fadeout-off.png new file mode 100755 index 0000000..378d9a5 Binary files /dev/null and b/assets/fadeout-off.png differ diff --git a/assets/fadeout-on.png b/assets/fadeout-on.png new file mode 100755 index 0000000..0d72241 Binary files /dev/null and b/assets/fadeout-on.png differ diff --git a/assets/grabar48x48.png b/assets/grabar48x48.png new file mode 100644 index 0000000..b623120 Binary files /dev/null and b/assets/grabar48x48.png differ diff --git a/assets/gradio.png b/assets/gradio.png new file mode 100755 index 0000000..a61a33f Binary files /dev/null and b/assets/gradio.png differ diff --git a/assets/hora-off.png b/assets/hora-off.png new file mode 100755 index 0000000..9c8eb97 Binary files /dev/null and b/assets/hora-off.png differ diff --git a/assets/hora-on.png b/assets/hora-on.png new file mode 100755 index 0000000..4a79896 Binary files /dev/null and b/assets/hora-on.png differ diff --git a/assets/opencode/AGENTS.md b/assets/opencode/AGENTS.md new file mode 100644 index 0000000..024d9b2 --- /dev/null +++ b/assets/opencode/AGENTS.md @@ -0,0 +1,50 @@ +Eres el asistente integrado de **G-Radio Player**. Los datos están en `~/.gradio/data/`. + +## Archivos de contexto (carga solo el necesario) + +| Cuándo leerlo | Archivo | +|---|---| +| Programación musical | `context/02-programacion.md` | +| Comerciales | `context/03-comerciales.md` | +| Eventos / eventos-espera | `context/04-eventos.md` | +| Control del player / diagnóstico | `context/06-comandos.md` | +| Duda sobre estructura general | `context/01-estructura.md` | + +**No leas los context/ de entrada. Léelos solo cuando el usuario elija una opción.** + +## Al iniciar: muestra este menú + +``` +🎙 ASISTENTE G-RADIO PLAYER + +1. 📋 PROGRAMACIÓN MUSICAL +2. 💼 PAUTAR COMERCIAL +3. 📅 PROGRAMAR EVENTO +4. ⏳ EVENTO EN ESPERA +5. 🎛 CONTROLAR EL PLAYER +6. 🔍 DIAGNÓSTICO +7. 🗂 GESTIONAR COLAS +8. 🎤 GENERAR LOCUCIÓN (IA) + +Elige un número o describe lo que necesitas: +``` + +## Opción 8 — Generar locución con IA + +Usa `locuta "Texto a locutar"` para generar locuciones con la voz clonada de Pato. +Requiere GPU NVIDIA (solo en este escritorio, no en RPi). El MP3 se guarda en `~/Dropbox/LocutorIA/salida_mp3/` y queda listo para copiar a `~/.gradio/data/panel/Time/` o usar en pautaje comercial/eventos. + +Para más detalles, leer `~/Dropbox/LocutorIA/COMO_USAR.md`. + +<|DSML|tool_calls> +<|DSML|invoke name="edit"> +<|DSML|parameter name="filePath" string="true">/home/evolucion/Dropbox/Claude/radio-player/git/assets/opencode/AGENTS.md + +## Reglas esenciales + +- Responde en **español** +- Lee `~/.gradio/data/tmp/estado.json` para ver estado actual +- Formato `.com`: `/ruta.mp3|máscara_días|AAAAMMDD|AAAAMMDD` +- Formato `playlist4`: `/ruta.mp300:MM:SS.000` +- Señales IPC: `touch ~/.gradio/data/tmp/cmd_play` (crear archivo, no escribir) +- Verifica que las rutas de audio existen antes de usarlas diff --git a/assets/opencode/agents/g-radio.md b/assets/opencode/agents/g-radio.md new file mode 100644 index 0000000..6b81b3b --- /dev/null +++ b/assets/opencode/agents/g-radio.md @@ -0,0 +1,125 @@ +--- +description: >- + Asistente experto en G-Radio Player. Úsalo para cualquier tarea relacionada + con el sistema: programación musical, pautaje de comerciales, eventos, + control del player, diagnóstico y gestión de colas. Responde siempre en español. +mode: all +permission: + webfetch: deny + websearch: deny + lsp: deny + task: deny + skill: deny +--- + +Eres el asistente integrado de **G-Radio Player**, sistema de automatización de radio profesional desarrollado en Rust + GTK4. + +## Sistema que controlas + +El player corre en esta máquina con tres procesos: +- `radio-player` — UI GTK4 con reproducción GStreamer, crossfade, duck de volumen +- `comercial-scheduler` — carga tandas al minuto :58 de cada hora +- `playlist-refill` — mantiene la cola musical con ~14 temas de la parrilla + +Los datos viven en `~/.gradio/data/`. El estado se actualiza en `~/.gradio/data/tmp/estado.json` cada 2 segundos. + +## Menú inicial + +Al comenzar la sesión, presenta este menú exacto: + +``` +🎙 ASISTENTE G-RADIO PLAYER + +¿Qué necesitas? + +1. 📋 VER / EDITAR PROGRAMACIÓN (parrilla musical) +2. 💼 PAUTAR COMERCIAL +3. 📅 PROGRAMAR EVENTO +4. ⏳ EVENTO EN ESPERA +5. 🎛 CONTROLAR EL PLAYER (play/stop/siguiente/volumen) +6. 🔍 DIAGNÓSTICO DEL SISTEMA +7. 🗂 GESTIONAR COLAS ACTIVAS + +Escribe el número o describe lo que necesitas: +``` + +## Flujos guiados por opción + +### Opción 1 — Programación musical +1. Leer el estado actual: `cat ~/.gradio/data/tmp/estado.json` +2. Mostrar qué está sonando ahora +3. Mostrar la parrilla del bloque actual: `cat ~/.gradio/data/parrilla//-.mus` +4. Preguntar: ¿ver, agregar o modificar? +5. Para modificar: mostrar el archivo, hacer el cambio, confirmar + +### Opción 2 — Pautar comercial +1. Preguntar: ruta del archivo de audio +2. Verificar que existe: `ls -la "/ruta/audio.mp3"` +3. Preguntar: hora y minuto de la tanda +4. Preguntar: días activos (todos / lunes-viernes / fin de semana / específicos) +5. Preguntar: fechas de vigencia (inicio y fin, formato DD/MM/AAAA) +6. Mostrar la línea que se va a agregar: `/ruta/audio.mp3|máscara|AAAAMMDD|AAAAMMDD` +7. Confirmar y ejecutar: + ```bash + mkdir -p ~/.gradio/data/comerciales/ + echo "/ruta/audio.mp3|||" >> ~/.gradio/data/comerciales//.com + ``` +8. Mostrar el archivo resultante + +### Opción 3 — Programar evento +- Igual que Opción 2 pero en `~/.gradio/data/eventos//.com` + +### Opción 4 — Evento en espera +- Igual que Opción 2 pero en `~/.gradio/data/eventos-espera//.com` +- También ofrecer: poner en cola inmediata (`echo "/ruta.mp3" > ~/.gradio/data/tmp/eventos-esperalist`) + +### Opción 5 — Controlar el player +Mostrar estado actual y ofrecer: +- ▶ Play: `touch ~/.gradio/data/tmp/cmd_play` +- ⏸ Pausa: `touch ~/.gradio/data/tmp/cmd_pause` +- ⏹ Stop: `touch ~/.gradio/data/tmp/cmd_stop` +- ⏭ Siguiente: `touch ~/.gradio/data/tmp/cmd_siguiente` +- ⏹⏹ Stop General: `touch ~/.gradio/data/tmp/cmd_stop_general` +- 🔊 Cambiar volumen: `echo "" > ~/.gradio/data/tmp/upvol` + +### Opción 6 — Diagnóstico +Ejecutar el script diagnóstico completo del archivo `context/06-comandos.md` y mostrar resultados interpretados. + +### Opción 7 — Gestionar colas +Mostrar contenido de todas las colas activas. Ofrecer: vaciar cola, agregar item, eliminar item específico. + +## Formatos importantes + +### Formato .com (comerciales / eventos / eventos-espera) +``` +/ruta/absoluta/audio.mp3|1234567|20250101|20251231 +``` +- Máscara días: `1234567`=todos, `12345`=L-V, `67`=finde, `135`=L-M-V +- Fechas: AAAAMMDD sin guiones + +### Formato playlist4 (cola musical) +``` +/ruta/absoluta/audio.mp3 00:04:32.000 +``` +- Separador TAB (no espacios) +- Duración en formato HH:MM:SS.mmm + +### Señales IPC (touch = activa, el player borra el archivo) +```bash +touch ~/.gradio/data/tmp/cmd_play +touch ~/.gradio/data/tmp/cmd_pause +touch ~/.gradio/data/tmp/cmd_stop +touch ~/.gradio/data/tmp/cmd_siguiente +touch ~/.gradio/data/tmp/cmd_stop_general +touch ~/.gradio/data/tmp/cmd_play_tanda +``` + +## Reglas de calidad + +- **Verificar rutas**: antes de agregar cualquier archivo de audio, comprobar que existe +- **Leer antes de escribir**: mostrar el archivo actual antes de modificarlo +- **Confirmar cambios**: mostrar la línea exacta antes de escribirla, pedir confirmación +- **No borrar sin confirmar**: si hay que eliminar líneas, mostrar cuáles y confirmar +- **Usar fechas reales**: preguntar vigencia, no asumir fechas genéricas +- **Respetar el formato**: el separador en `.com` es `|`, nunca comas ni espacios +- Responder siempre en **español** diff --git a/assets/opencode/context/01-estructura.md b/assets/opencode/context/01-estructura.md new file mode 100644 index 0000000..0855564 --- /dev/null +++ b/assets/opencode/context/01-estructura.md @@ -0,0 +1,115 @@ +# Estructura de G-Radio-Player + +Sistema de automatización de radio profesional en **Rust + GTK4**. Tres procesos coordinados que se comunican mediante archivos compartidos en `~/.gradio/data/tmp/`. + +## Binarios instalados en `/usr/local/bin/` + +| Binario | Descripción | +|---|---| +| `radio-player` | UI principal GTK4 — reproduce música, maneja crossfade, Duck | +| `comercial-scheduler` | Daemon que se activa en el minuto :58 y carga las tandas del minuto siguiente | +| `playlist-refill` | Daemon que revisa cada 5s y rellena `playlist4` con ~14 temas de la parrilla | +| `gradio.sh` | Lanzador principal (mata procesos previos, inicia los 3 procesos + watchdog) | +| `gr-botonera` | Ventana de botonera de efectos y jingles rápidos | +| `gr-parrilla` | Editor de parrilla semanal (qué suena cada hora) | +| `gr-pautaje` | Editor de pautaje comercial (qué comerciales suenan cada minuto) | +| `gr-playlist` | Editor de playlist | +| `gr-buscador` | Buscador de archivos de audio con preescucha | +| `gr-visor` | Visor de reportes y programación | +| `gr-reportes` | Generador de reportes de reproducción | +| `gr-record` | Grabación de audio | + +## Árbol de datos `~/.gradio/data/` + +``` +~/.gradio/data/ +├── tmp/ ← Estado en vivo (leer/escribir aquí) +│ ├── gradio.config ← Configuración principal (18 líneas) +│ ├── playlist4 ← Cola musical: "ruta\tduracion" por línea +│ ├── comercialeslist4 ← Cola activa de comerciales (solo rutas) +│ ├── eventoslist ← Cola activa de eventos (solo rutas) +│ ├── eventos-esperalist ← Cola activa de eventos en espera +│ ├── estado.json ← Estado completo del player (se escribe cada 2s) +│ ├── upvol ← Volumen principal 0-100 +│ ├── downvol ← Volumen duck 0-100 +│ ├── cmd_play ← Señal: reproducir (crear el archivo para activar) +│ ├── cmd_pause ← Señal: pausar +│ ├── cmd_stop ← Señal: detener deck A +│ ├── cmd_stop_after ← Señal: toggle "detener al terminar el tema" +│ ├── cmd_siguiente ← Señal: saltar al siguiente tema +│ ├── cmd_stop_general ← Señal: detener TODO (música + comerciales + eventos) +│ ├── cmd_play_tanda ← Señal: reproducir tanda de comerciales inmediata +│ ├── botonera_now ← Señal: ruta de audio a reproducir en botonera +│ ├── play_now ← Señal: "ruta\tduracion" para reproducir ahora +│ └── played_breaks ← Tandas ya reproducidas hoy (YYYYMMDD HH:MM por línea) +│ +├── parrilla/ ← Programación semanal de música +│ ├── 1/ ← Lunes (1=Lun, 2=Mar, ..., 7=Dom) +│ │ ├── 0-1.mus ← Bloque 00:00-01:00 del lunes +│ │ ├── 8-9.mus +│ │ └── ... +│ ├── 2/ ... 7/ +│ +├── comerciales/ ← Pautaje de comerciales +│ ├── 0/ ← Hora 0 (00:xx) +│ │ ├── 0.com ← Comerciales a las 00:00 +│ │ ├── 30.com ← Comerciales a las 00:30 +│ │ └── ... +│ └── 1/ ... 23/ +│ +├── eventos/ ← Eventos programados (mismo formato .com) +│ └── /.com +│ +├── eventos-espera/ ← Eventos en espera del operador +│ └── /.com +│ +├── botonera/config.json ← Botones configurados en gr-botonera +│ +├── panel/Time/ ← 109 audios de hora (HRS00..HRS23, MIN01..MIN59, etc.) +│ +└── reporte/ ← Reportes CSV de reproducción +``` + +## Archivo `gradio.config` (18 líneas) + +``` +línea 1: tarjeta audio principal (vacío = auto) +línea 2: tarjeta audio CUE (vacío = auto) +línea 3: nombre del medio +línea 4: tiempo de fundido crossfade (segundos) +línea 5: pisador habilitado (1/0) +línea 6: carpeta de pisadores +línea 7: cada cuántos temas tocar pisador +línea 8: carpetas excluidas de pisador (formato: "/ruta/a";"/ruta/b") +línea 9: silencio máximo en segundos (0=desactivado) +línea 10: carpetas de música nacional (para reportes) +línea 11: carpetas de interculturalidad (para reportes) +línea 12: puerto servidor gr-client (default 7777) +línea 13: token de autenticación gr-client +línea 14: relay habilitado (1/0) +línea 15: ID de relay (8 dígitos) +línea 16: días de no-repetición (default 3) +línea 17: locale ("es"/"en"/"pt" o vacío=auto) +línea 18: IA habilitada (1/0) +``` + +## Archivo `estado.json` (se actualiza cada 2 segundos) + +```json +{ + "reproduciendo": true, + "pausado": false, + "track_actual": "/home/usuario/Música/tema.mp3", + "titulo_actual": "Nombre del tema", + "posicion_secs": 45.3, + "duracion_secs": 210.0, + "upvol": 90, + "downvol": 20, + "comerciales_activos": false, + "eventos_activos": false, + "num_comerciales": 3, + "num_eventos": 0, + "num_eventos_espera": 1, + "indice_actual": 7 +} +``` diff --git a/assets/opencode/context/02-programacion.md b/assets/opencode/context/02-programacion.md new file mode 100644 index 0000000..4e76704 --- /dev/null +++ b/assets/opencode/context/02-programacion.md @@ -0,0 +1,90 @@ +# Programación Musical (Parrilla) + +La **parrilla** define qué música suena en cada bloque horario de cada día de la semana. + +## Archivos `.mus` + +Ruta: `~/.gradio/data/parrilla//-.mus` + +- **Día**: 1=Lunes, 2=Martes, 3=Miércoles, 4=Jueves, 5=Viernes, 6=Sábado, 7=Domingo +- **Hora**: bloque de una hora (0-1, 1-2, ..., 23-24) +- Ejemplo: `~/.gradio/data/parrilla/1/10-11.mus` = Lunes de 10:00 a 11:00 + +## Formato de un archivo `.mus` + +Cada línea puede ser: + +``` +/home/usuario/Música/tema_exacto.mp3 ← archivo específico +/home/usuario/Música/Salsa/* ← archivo ALEATORIO de esa carpeta (comodín *) +Hora ← insertar anuncio de hora del sistema +``` + +Ejemplo real: +``` +/home/grecord/Musica/Inicio/intro.mp3 +/home/grecord/Musica/Pop/* +/home/grecord/Musica/Pop/* +Hora +/home/grecord/Musica/Rock/* +/home/grecord/Musica/Baladas/* +``` + +## Cola activa `playlist4` + +`~/.gradio/data/tmp/playlist4` contiene la playlist en ejecución. Formato TAB-separado: + +``` +/ruta/absoluta/al/archivo.mp3\t00:03:45.000 +/ruta/absoluta/al/archivo2.mp3\t00:04:12.000 +``` + +El daemon `playlist-refill` mantiene esta cola con ~14 temas. Cada tema consumido se elimina de la primera línea. + +## Cómo ver la programación actual + +```bash +# Qué tema está sonando ahora +cat ~/.gradio/data/tmp/estado.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['titulo_actual'], '-', d['track_actual'])" + +# Cola de música pendiente +cat ~/.gradio/data/tmp/playlist4 + +# Parrilla del día de hoy (ej. lunes = día 1) +DIA=$(date +%u) # 1=Lun...7=Dom +HORA=$(date +%-H) +cat ~/.gradio/data/parrilla/$DIA/$HORA-$((HORA+1)).mus +``` + +## Cómo modificar la programación + +```bash +# Agregar un tema al frente de la cola actual (próximo a reproducir) +TEMA="/ruta/al/archivo.mp3" +DUR="00:03:45.000" +(echo -e "$TEMA\t$DUR"; cat ~/.gradio/data/tmp/playlist4) > /tmp/pl_tmp && mv /tmp/pl_tmp ~/.gradio/data/tmp/playlist4 + +# Agregar un tema al final de la cola +echo -e "/ruta/al/archivo.mp3\t00:04:00.000" >> ~/.gradio/data/tmp/playlist4 + +# Editar la parrilla de mañana (agregar tema a las 9:00-10:00 del martes) +echo "/ruta/nuevo_tema.mp3" >> ~/.gradio/data/parrilla/2/9-10.mus + +# Ver toda la programación de la semana para una hora específica +for dia in 1 2 3 4 5 6 7; do + echo "=== Día $dia ===" + cat ~/.gradio/data/parrilla/$dia/10-11.mus 2>/dev/null || echo "(vacío)" +done +``` + +## Días con nombres + +| Número | Día | +|--------|-----| +| 1 | Lunes | +| 2 | Martes | +| 3 | Miércoles | +| 4 | Jueves | +| 5 | Viernes | +| 6 | Sábado | +| 7 | Domingo | diff --git a/assets/opencode/context/03-comerciales.md b/assets/opencode/context/03-comerciales.md new file mode 100644 index 0000000..0388063 --- /dev/null +++ b/assets/opencode/context/03-comerciales.md @@ -0,0 +1,103 @@ +# Comerciales (Pautaje) + +Los **comerciales** son audios programados para sonar a horas y minutos específicos, con control de días activos y fechas de vigencia. + +## Archivos `.com` + +Ruta: `~/.gradio/data/comerciales//.com` + +- Hora: 0 a 23 (sin cero a la izquierda) +- Minuto: 0 a 59 (sin cero a la izquierda) +- Ejemplo: `~/.gradio/data/comerciales/15/30.com` = tanda de las 15:30 + +## Formato de cada línea en un `.com` + +``` +/ruta/absoluta/al/audio.mp3|1234567|20250101|20251231 +``` + +| Campo | Significado | +|-------|-------------| +| `/ruta/...` | Ruta absoluta al archivo de audio | +| `1234567` | Máscara de días activos: cada dígito = un día (1=Lun, 2=Mar, 3=Mié, 4=Jue, 5=Vie, 6=Sáb, 7=Dom). `1234567` = todos los días | +| `20250101` | Fecha de inicio de vigencia (AAAAMMDD) | +| `20251231` | Fecha de fin de vigencia (AAAAMMDD) | + +### Ejemplos de máscaras de días + +``` +1234567 → todos los días +12345 → lunes a viernes +67 → sábado y domingo +135 → lunes, miércoles, viernes +1 → solo lunes +``` + +### Ejemplos de entradas `.com` + +``` +/home/grecord/Comerciales/spot_banco.mp3|12345|20250601|20250630 +/home/grecord/Comerciales/promo_fin_semana.mp3|67|20250101|20251231 +/home/grecord/Comerciales/jingle_general.mp3|1234567|20250101|20261231 +``` + +## Cola activa `comercialeslist4` + +`~/.gradio/data/tmp/comercialeslist4` contiene la cola que el player está procesando **ahora**. Solo tiene rutas (ya sin máscara ni fechas, esas ya fueron evaluadas por el scheduler): + +``` +/home/grecord/Comerciales/spot_banco.mp3 +/home/grecord/Comerciales/jingle_general.mp3 +``` + +## Archivo `played_breaks` + +`~/.gradio/data/tmp/played_breaks` registra las tandas que ya fueron reproducidas manualmente hoy (para que el scheduler no las vuelva a disparar): + +``` +20250627 15:30 +20250627 16:00 +``` + +## Comandos útiles + +```bash +# Ver la tanda de las 15:30 +cat ~/.gradio/data/comerciales/15/30.com + +# Ver todos los comerciales programados para hoy (lunes = día 1) +HOY=$(date +%u) +for h in $(seq 0 23); do + for f in ~/.gradio/data/comerciales/$h/*.com; do + [ -f "$f" ] || continue + grep "|.*$HOY" "$f" && echo " → $f" + done +done + +# Ver qué está en cola activa +cat ~/.gradio/data/tmp/comercialeslist4 + +# Agregar un comercial a las 16:00 de hoy (todos los días, vigente todo el año) +echo "/ruta/spot.mp3|1234567|20250101|20261231" >> ~/.gradio/data/comerciales/16/0.com + +# Crear directorio si no existe +mkdir -p ~/.gradio/data/comerciales/16 +echo "/ruta/spot.mp3|1234567|20250101|20261231" >> ~/.gradio/data/comerciales/16/0.com + +# Reproducir tanda inmediatamente (sin esperar al scheduler) +echo "/ruta/spot.mp3" > ~/.gradio/data/tmp/comercialeslist4 +touch ~/.gradio/data/tmp/cmd_play_tanda + +# Ver tandas ya reproducidas hoy +cat ~/.gradio/data/tmp/played_breaks +``` + +## Cómo funciona el scheduler + +El daemon `comercial-scheduler` (Rust) se activa en el minuto **:58** de cada hora y: +1. Lee los archivos `.com` del minuto y hora siguientes +2. Filtra por día activo (máscara) y fechas de vigencia +3. Carga las rutas válidas en `comercialeslist4` +4. El player las procesa cuando llega el momento + +Si una tanda aparece en `played_breaks` con fecha/hora de hoy, el scheduler la salta (ya se reprodujo manualmente). diff --git a/assets/opencode/context/04-eventos.md b/assets/opencode/context/04-eventos.md new file mode 100644 index 0000000..d9d70f5 --- /dev/null +++ b/assets/opencode/context/04-eventos.md @@ -0,0 +1,113 @@ +# Eventos + +Los **eventos** son acciones programadas que suenan de forma emergente: interrumpen la música brevemente, reproducen el audio, y la música se reanuda. Comparten el mismo formato `.com` que los comerciales. + +## Archivos `.com` de eventos + +Ruta: `~/.gradio/data/eventos//.com` + +- Mismo formato que comerciales: `/ruta/audio.mp3|máscara_días|fecha_inicio|fecha_fin` +- Ejemplo: `~/.gradio/data/eventos/12/0.com` = evento del mediodía + +## Cola activa `eventoslist` + +`~/.gradio/data/tmp/eventoslist` — lista de rutas que el player procesará como evento emergente. + +El player detecta cuando esta cola tiene contenido y la procesa de inmediato (interrumpe brevemente la música). + +## Diferencia entre Comerciales, Eventos y Eventos-en-Espera + +| Aspecto | Comerciales | Eventos | Eventos-en-Espera | +|---------|-------------|---------|-------------------| +| Directorio | `data/comerciales/` | `data/eventos/` | `data/eventos-espera/` | +| Cola activa | `tmp/comercialeslist4` | `tmp/eventoslist` | `tmp/eventos-esperalist` | +| Típico uso | Publicidad programada | Cuñas institucionales, separadores | Acciones espontáneas del operador | +| Scheduler | `comercial-scheduler` | `comercial-scheduler` | `comercial-scheduler` | + +## Comandos útiles + +```bash +# Ver eventos programados para hoy a las 12:00 +cat ~/.gradio/data/eventos/12/0.com + +# Ver cola activa de eventos +cat ~/.gradio/data/tmp/eventoslist + +# Disparar un evento inmediatamente (reproducir ahora) +echo "/ruta/cuña.mp3" > ~/.gradio/data/tmp/eventoslist + +# Agregar evento al mediodía todos los días +mkdir -p ~/.gradio/data/eventos/12 +echo "/ruta/cuña_mediodia.mp3|1234567|20250101|20261231" >> ~/.gradio/data/eventos/12/0.com + +# Vaciar la cola activa de eventos +> ~/.gradio/data/tmp/eventoslist + +# Ver estado general del sistema (incluye num_eventos) +cat ~/.gradio/data/tmp/estado.json | python3 -c "import sys,json; d=json.load(sys.stdin); print('Eventos en cola:', d['num_eventos'])" +``` + +## Estado en `estado.json` + +El campo `eventos_activos` indica si hay un evento reproduciéndose ahora mismo. `num_eventos` muestra cuántos hay en cola. + +# Eventos en Espera + +Los **eventos en espera** son acciones programadas que el operador prepara con antelación para disparar cuando lo decida, o que el scheduler carga automáticamente. Son el equivalente de una "lista de espera" de efectos. + +## Archivos `.com` + +Ruta: `~/.gradio/data/eventos-espera//.com` + +- Mismo formato que comerciales y eventos: `/ruta/audio.mp3|máscara_días|fecha_inicio|fecha_fin` +- Ejemplo: `~/.gradio/data/eventos-espera/9/30.com` = evento en espera a las 9:30 + +## Cola activa `eventos-esperalist` + +`~/.gradio/data/tmp/eventos-esperalist` — lista de rutas pendientes en espera. + +El player la monitorea: cuando tiene contenido, lo reproduce en modo emergente (igual que `eventoslist`). + +## Uso típico + +Los eventos en espera se usan para: +- Anuncios de noticias que salen al aire cuando el operador decide +- Efectos especiales cargados con antelación +- Separadores y cortinas que se reproducen bajo demanda + +## Comandos útiles + +```bash +# Ver cola de eventos en espera +cat ~/.gradio/data/tmp/eventos-esperalist + +# Poner un audio en la cola de espera (listo para reproducir) +echo "/ruta/noticia_urgente.mp3" > ~/.gradio/data/tmp/eventos-esperalist + +# Agregar sin borrar los anteriores +echo "/ruta/efecto.mp3" >> ~/.gradio/data/tmp/eventos-esperalist + +# Vaciar la cola de espera +> ~/.gradio/data/tmp/eventos-esperalist + +# Programar evento en espera para las 10:30 todos los lunes-viernes +mkdir -p ~/.gradio/data/eventos-espera/10 +echo "/ruta/noticia.mp3|12345|20250601|20251231" >> ~/.gradio/data/eventos-espera/10/30.com + +# Estado: cuántos hay en la cola de espera +cat ~/.gradio/data/tmp/estado.json | python3 -c "import sys,json; d=json.load(sys.stdin); print('En espera:', d['num_eventos_espera'])" +``` + +## Resumen de las tres colas + +```bash +# Ver el estado de todas las colas a la vez +echo "=== MÚSICA (playlist4) ===" +wc -l ~/.gradio/data/tmp/playlist4 +echo "=== COMERCIALES activos ===" +cat ~/.gradio/data/tmp/comercialeslist4 +echo "=== EVENTOS activos ===" +cat ~/.gradio/data/tmp/eventoslist +echo "=== EVENTOS EN ESPERA ===" +cat ~/.gradio/data/tmp/eventos-esperalist +``` diff --git a/assets/opencode/context/06-comandos.md b/assets/opencode/context/06-comandos.md new file mode 100644 index 0000000..c105e15 --- /dev/null +++ b/assets/opencode/context/06-comandos.md @@ -0,0 +1,180 @@ +# Comandos y Control del Sistema + +## Control de reproducción (señales IPC) + +El player monitorea la carpeta `~/.gradio/data/tmp/` cada 200ms. Para enviar un comando, **crea el archivo** — el player lo lee, ejecuta la acción y borra el archivo. + +```bash +TMP=~/.gradio/data/tmp + +# Reproducir (reanudar desde pausa) +touch "$TMP/cmd_play" + +# Pausar / reanudar (toggle) +touch "$TMP/cmd_pause" + +# Detener deck A (deja los comerciales intactos) +touch "$TMP/cmd_stop" + +# Toggle "detener al terminar el tema actual" +touch "$TMP/cmd_stop_after" + +# Saltar al siguiente tema +touch "$TMP/cmd_siguiente" + +# STOP GENERAL: detiene música + comerciales + eventos todo +touch "$TMP/cmd_stop_general" + +# Reproducir tanda de comerciales AHORA (sin esperar al scheduler) +# Primero cargar la lista, luego la señal +echo "/ruta/spot1.mp3" > "$TMP/comercialeslist4" +echo "/ruta/spot2.mp3" >> "$TMP/comercialeslist4" +touch "$TMP/cmd_play_tanda" + +# Reproducir audio en botonera (sin interrumpir música ni comerciales) +echo "/ruta/efecto.mp3" > "$TMP/botonera_now" + +# Reproducir tema inmediatamente (crossfade hacia él) +echo -e "/ruta/tema.mp3\t00:03:45.000" > "$TMP/play_now" +``` + +## Estado del sistema + +```bash +# Estado completo (JSON actualizado cada 2s) +cat ~/.gradio/data/tmp/estado.json + +# Estado formateado +cat ~/.gradio/data/tmp/estado.json | python3 -m json.tool + +# Valores individuales +python3 -c " +import json +d = json.load(open('$HOME/.gradio/data/tmp/estado.json')) +print(f'Reproduciendo: {d[\"reproduciendo\"]}') +print(f'Tema actual: {d[\"titulo_actual\"]}') +print(f'Posición: {d[\"posicion_secs\"]:.0f}s / {d[\"duracion_secs\"]:.0f}s') +print(f'Volumen: up={d[\"upvol\"]}% duck={d[\"downvol\"]}%') +print(f'Comerciales: {d[\"num_comerciales\"]} en cola, activos={d[\"comerciales_activos\"]}') +print(f'Eventos: {d[\"num_eventos\"]}') +print(f'En espera: {d[\"num_eventos_espera\"]}') +" + +# Playlist pendiente (cuántos temas quedan) +wc -l ~/.gradio/data/tmp/playlist4 + +# Cola de comerciales activa +cat ~/.gradio/data/tmp/comercialeslist4 +``` + +## Volumen + +```bash +# Ver volumen actual +echo "Principal: $(cat ~/.gradio/data/tmp/upvol)%" +echo "Duck: $(cat ~/.gradio/data/tmp/downvol)%" + +# Cambiar volumen principal a 85% +echo "85" > ~/.gradio/data/tmp/upvol + +# Cambiar volumen duck a 20% +echo "20" > ~/.gradio/data/tmp/downvol +``` + +## Parrilla (programación) + +```bash +# Ver qué suena ahora +HORA=$(date +%-H) +DIA=$(date +%u) # 1=Lun...7=Dom +echo "=== Parrilla actual: día $DIA, hora $HORA ===" +cat ~/.gradio/data/parrilla/$DIA/$HORA-$((HORA+1)).mus + +# Agregar tema a la hora actual +echo "/ruta/nuevo_tema.mp3" >> ~/.gradio/data/parrilla/$DIA/$HORA-$((HORA+1)).mus + +# Crear parrilla para todas las horas de un día (comodín) +for h in $(seq 0 23); do + mkdir -p ~/.gradio/data/parrilla/1 + echo "/home/grecord/Musica/Variado/*" >> ~/.gradio/data/parrilla/1/$h-$((h+1)).mus +done +``` + +## Comerciales + +```bash +# Agregar comercial a las 16:00 (todos los días, todo el año) +mkdir -p ~/.gradio/data/comerciales/16 +echo "/ruta/spot.mp3|1234567|20250101|20261231" >> ~/.gradio/data/comerciales/16/0.com + +# Ver tandas de la hora actual +HORA=$(date +%-H) +MIN=$(date +%-M) +ls ~/.gradio/data/comerciales/$HORA/ + +# Ver tandas ya reproducidas hoy +cat ~/.gradio/data/tmp/played_breaks +``` + +## Locución con IA + +```bash +# Generar locución con voz clonada de Pato (requiere GPU NVIDIA) +locuta "Texto a locutar" + +# Especificar nombre de archivo +cd ~/Dropbox/LocutorIA +./locucion_pato.sh "Texto a locutar" nombre_salida + +# La locución se genera en: +# ~/Dropbox/LocutorIA/salida_mp3/locucion_AAAAMMDD_HHMMSS.mp3 +# (o salida_mp3/nombre_salida.mp3 si se usó nombre personalizado) + +# Copiar a la carpeta Time de G Radio Player para usar como locución de hora: +cp ~/Dropbox/LocutorIA/salida_mp3/locucion_*.mp3 ~/.gradio/data/panel/Time/ + +# O copiar a la carpeta de comerciales/eventos para pautar: +cp ~/Dropbox/LocutorIA/salida_mp3/mi_locucion.mp3 ~/.gradio/data/comerciales/15/ +``` + +## Verificar daemons + +```bash +# Ver si los daemons están corriendo +ps aux | grep -E 'comercial-scheduler|playlist-refill' | grep -v grep + +# Verificar que el player está activo +ps aux | grep radio-player | grep -v grep + +# Leer configuración actual +cat ~/.gradio/data/tmp/gradio.config +``` + +## Diagnóstico rápido + +```bash +# Script de diagnóstico completo +echo "=== G-Radio Player — Estado ===" +echo "" +echo "Procesos:" +ps aux | grep -E 'radio-player|comercial-scheduler|playlist-refill' | grep -v grep + +echo "" +echo "Reproducción:" +python3 -c " +import json, sys +try: + d = json.load(open('$HOME/.gradio/data/tmp/estado.json')) + print(f' Reproduciendo: {d[\"reproduciendo\"]}') + print(f' Tema: {d[\"titulo_actual\"]}') + print(f' Posición: {d[\"posicion_secs\"]:.0f}s / {d[\"duracion_secs\"]:.0f}s') +except: print(' (estado.json no disponible)') +" + +echo "" +echo "Colas:" +echo " Música pendiente: $(wc -l < ~/.gradio/data/tmp/playlist4 2>/dev/null || echo 0) temas" +echo " Comerciales: $(wc -l < ~/.gradio/data/tmp/comercialeslist4 2>/dev/null | tr -d ' ') items" +echo " Eventos: $(wc -l < ~/.gradio/data/tmp/eventoslist 2>/dev/null | tr -d ' ') items" +echo " En espera: $(wc -l < ~/.gradio/data/tmp/eventos-esperalist 2>/dev/null | tr -d ' ') items" +``` diff --git a/assets/opencode/opencode.jsonc b/assets/opencode/opencode.jsonc new file mode 100644 index 0000000..720ece5 --- /dev/null +++ b/assets/opencode/opencode.jsonc @@ -0,0 +1,3 @@ +{ + "$schema": "https://opencode.ai/config.json" +} diff --git a/assets/parrilla-aunahora.png b/assets/parrilla-aunahora.png new file mode 100644 index 0000000..3b05ae7 Binary files /dev/null and b/assets/parrilla-aunahora.png differ diff --git a/assets/parrilla48x48.png b/assets/parrilla48x48.png new file mode 100644 index 0000000..ce3015e Binary files /dev/null and b/assets/parrilla48x48.png differ diff --git a/assets/pausa.png b/assets/pausa.png new file mode 100755 index 0000000..da79f66 Binary files /dev/null and b/assets/pausa.png differ diff --git a/assets/pautaje48x48.png b/assets/pautaje48x48.png new file mode 100644 index 0000000..6d4bb82 Binary files /dev/null and b/assets/pautaje48x48.png differ diff --git a/assets/pisador.png b/assets/pisador.png new file mode 100755 index 0000000..d80e344 Binary files /dev/null and b/assets/pisador.png differ diff --git a/assets/play-off.png b/assets/play-off.png new file mode 100755 index 0000000..867279e Binary files /dev/null and b/assets/play-off.png differ diff --git a/assets/play-on.png b/assets/play-on.png new file mode 100755 index 0000000..72f1a57 Binary files /dev/null and b/assets/play-on.png differ diff --git a/assets/playlist.png b/assets/playlist.png new file mode 100644 index 0000000..1968a40 Binary files /dev/null and b/assets/playlist.png differ diff --git a/assets/playlist48x48.png b/assets/playlist48x48.png new file mode 100644 index 0000000..cc1c6c5 Binary files /dev/null and b/assets/playlist48x48.png differ diff --git a/assets/processor-config.png b/assets/processor-config.png new file mode 100644 index 0000000..8aadbbd Binary files /dev/null and b/assets/processor-config.png differ diff --git a/assets/processor-off.png b/assets/processor-off.png new file mode 100644 index 0000000..9a6cd43 Binary files /dev/null and b/assets/processor-off.png differ diff --git a/assets/processor-off.svg b/assets/processor-off.svg new file mode 100644 index 0000000..31c997a --- /dev/null +++ b/assets/processor-off.svg @@ -0,0 +1,566 @@ + + + + diff --git a/assets/processor-on.png b/assets/processor-on.png new file mode 100644 index 0000000..ea80729 Binary files /dev/null and b/assets/processor-on.png differ diff --git a/assets/processor-on.svg b/assets/processor-on.svg new file mode 100644 index 0000000..53884f5 --- /dev/null +++ b/assets/processor-on.svg @@ -0,0 +1,747 @@ + + + + diff --git a/assets/rec-off.png b/assets/rec-off.png new file mode 100755 index 0000000..f47aec7 Binary files /dev/null and b/assets/rec-off.png differ diff --git a/assets/rec-on.png b/assets/rec-on.png new file mode 100755 index 0000000..bbfb576 Binary files /dev/null and b/assets/rec-on.png differ diff --git a/assets/repetir.png b/assets/repetir.png new file mode 100755 index 0000000..4e2fc7b Binary files /dev/null and b/assets/repetir.png differ diff --git a/assets/reportes.png b/assets/reportes.png new file mode 100644 index 0000000..007591f Binary files /dev/null and b/assets/reportes.png differ diff --git a/assets/robot.png b/assets/robot.png new file mode 100644 index 0000000..d45eabf Binary files /dev/null and b/assets/robot.png differ diff --git a/assets/sello.png b/assets/sello.png new file mode 100644 index 0000000..88ff99b Binary files /dev/null and b/assets/sello.png differ diff --git a/assets/siguiente.png b/assets/siguiente.png new file mode 100755 index 0000000..dd3b444 Binary files /dev/null and b/assets/siguiente.png differ diff --git a/assets/skins/cyan/estilo.css b/assets/skins/cyan/estilo.css new file mode 100644 index 0000000..4480e30 --- /dev/null +++ b/assets/skins/cyan/estilo.css @@ -0,0 +1,57 @@ +/* Skin "cyan" — paleta moderna azul-marino/turquesa. Solo colores: + mantiene el layout de G Radio Player intacto. + + NOTA GTK4: los overrides de color de fondo usan background-image: image() + en vez de background-color, porque el tema del sistema puede pisar + background-color pero no background-image (ver LEEME.txt en ejemplo/). */ + +window { background-image: image(#0e1420); color: #dbe6f0; } + +.header-bar { background-image: image(#131a28); border-bottom: 2px solid #1f2c3d; } + +.clock-label { color: #22d3c5; } +.station-name-lbl { color: #22d3c5; } + +.now-playing-bar { background-image: image(#161f30); } +.now-playing-label { color: #22d3c5; } + +.version-lbl { color: #5c7a9c; } + +.deck-frame { background-image: image(#131a28); border: 1px solid #22d3c5; } +.deck-frame > label { background-image: image(#1c2740); color: #22d3c5; } + +.track-title { background-image: image(#1f2c46); } +.time-label { color: #22d3c5; } + +scale trough { background-image: image(#1c2740); } +scale highlight { background-image: image(#22d3c5); } +scale slider { background-image: image(#22d3c5); border-color: #1aa89c; } + +.control-btn { background-image: image(#22d3c5); color: #0b1622; border-color: #1aa89c; } +.control-btn:hover { background-image: image(#1aa89c); color: #ffffff; } +.control-btn:active { background-image: image(#12857c); color: #ffffff; } + +button.deck-icon-btn { background-image: image(#1c2740); border-color: #22d3c5; } +button.deck-icon-btn:hover { background-image: image(#22364f); border-color: #4ee0d4; } +button.deck-icon-btn:active { background-image: image(#0f1928); } + +button.global-icon-btn { background-image: image(#1c2740); border-color: #22d3c5; } +button.global-icon-btn:hover { background-image: image(#22364f); border-color: #4ee0d4; } +button.global-icon-btn:active { background-image: image(#0f1928); } +button.global-icon-btn:checked { background-image: image(#12857c); border-color: #22d3c5; } + +.ext-sq-btn { background-image: image(#1c2740); color: #22d3c5; border-color: #1aa89c; } +.ext-sq-btn:hover { background-image: image(#1aa89c); color: #ffffff; } + +.duck-btn { background-image: image(#22d3c5); color: #0b1622; border-color: #1aa89c; } +.duck-btn:checked, .duck-btn:hover { background-image: image(#1aa89c); color: #ffffff; } + +.list-frame { background-image: image(#0f1522); border-color: #1f2c3d; } +.list-frame > label { background-image: image(#1c2740); } + +.queue-list row { background-image: image(#0f1522); } +.queue-list row:nth-child(even) { background-image: image(#141c2c); } +.queue-list row:hover { background-image: image(#1c2740); } + +.playing-row { background-image: image(#12857c); } +button.queue-idx-btn { background-image: image(#7c6fe0); } diff --git a/assets/skins/ejemplo/LEEME.txt b/assets/skins/ejemplo/LEEME.txt new file mode 100644 index 0000000..e232ce7 --- /dev/null +++ b/assets/skins/ejemplo/LEEME.txt @@ -0,0 +1,66 @@ +G Radio Player — cómo crear un skin +==================================== + +Un skin es una carpeta dentro de ~/.gradio/data/skins/ que reemplaza iconos, +colores y/o el fondo de las ventanas, sin tocar el programa. Para crear uno +nuevo, copiá esta carpeta "ejemplo" con otro nombre (o creá una carpeta +nueva) y agregá dentro lo que quieras personalizar. Todo es opcional: un +skin puede traer solo iconos, solo colores, solo un fondo, o cualquier +combinación. + +Estructura: + + ~/.gradio/data/skins// + iconos/.png ← reemplazo de un ícono puntual + fondo.png (o .jpg) ← imagen de fondo de las ventanas + estilo.css ← reglas CSS adicionales (GTK4 CSS) + LEEME.txt ← notas propias, opcional + +Iconos reemplazables +--------------------- +Dentro de iconos/ podés colocar cualquiera de estos archivos (incluí solo +los que quieras cambiar; el resto sigue usando el ícono original del +programa). El nombre debe ser exactamente igual al de la lista: + + 24-Lopp-off.png 24-Lopp-on.png botonera48x48.png + busqueda.png config48x48.png detener-siguiente.png + down.png fadeout-off.png fadeout-on.png + GR-Off.png GR-On.png grabar48x48.png + gradio.png hora-off.png hora-on.png + parrilla48x48.png pausa.png pautaje48x48.png + play-off.png play-on.png playlist.png + playlist48x48.png processor-config.png processor-off.png + processor-on.png rec-off.png rec-on.png + repetir.png reportes.png robot.png + sello.png siguiente.png stop.png + trash.png up.png vaciar.png + visor48x48.png + +Fondo +----- +Si agregás fondo.png, fondo.jpg o fondo.jpeg, se usa como fondo de cada +ventana (imagen escalada para cubrir toda la ventana). + +CSS adicional +------------- +estilo.css se agrega por encima de todo el CSS del programa, así que +cualquier regla que pongas ahí (colores, bordes, tipografías) tiene +prioridad. + +IMPORTANTE — bug conocido de GTK4: el tema del sistema pisa "background-color" +y "background" en los selectores comunes (window, box, button), pero NO pisa +"background-image". Por eso, para poner un color de fondo sólido hay que +usar siempre background-image con la función image(), nunca background-color: + + window { background-image: image(#1a0000); } ← correcto, se ve + window { background-color: #1a0000; } ← el tema lo ignora + +Esto mismo aplica a cualquier otro selector (box, button, label, etc.) al +que le quieras poner un color de fondo. + +Cómo activarlo +--------------- +En radio-player, abrí Configuración → Skin (apariencia), elegí el skin y +guardá. El cambio se aplica la próxima vez que se abra cada ventana +(radio-player y las demás herramientas: pautaje, parrilla, botonera, +visor, playlist, buscador, grabador, reportes). diff --git a/assets/skins/green/estilo.css b/assets/skins/green/estilo.css new file mode 100644 index 0000000..ed9a728 --- /dev/null +++ b/assets/skins/green/estilo.css @@ -0,0 +1,58 @@ +/* Skin "green" — inspirado en la consola de radiodifusión profesional + clásica (verde/ámbar/azul sobre gris acero). Solo colores: mantiene + el layout de G Radio Player intacto. + + NOTA GTK4: los overrides de color de fondo usan background-image: image() + en vez de background-color, porque el tema del sistema puede pisar + background-color pero no background-image (ver LEEME.txt en ejemplo/). */ + +window { background-image: image(#20242b); color: #d8dde3; } + +.header-bar { background-image: image(#14171c); border-bottom: 2px solid #3a4a3a; } + +.clock-label { color: #3ddc61; } +.station-name-lbl { color: #ffcc44; } + +.now-playing-bar { background-image: image(#2d3a2d); } +.now-playing-label { color: #3ddc61; } + +.version-lbl { color: #7f96b0; } + +.deck-frame { background-image: image(#2a2f26); border: 1px solid #4a5a3a; } +.deck-frame > label { background-image: image(#3a4a30); color: #d8e8c8; } + +.track-title { background-image: image(#2a5a8a); } +.time-label { color: #3ddc61; } + +scale trough { background-image: image(#3a3a3a); } +scale highlight { background-image: image(#3f8f3f); } +scale slider { background-image: image(#4caf50); border-color: #2e7d32; } + +.control-btn { background-image: image(#e9e4d0); color: #1a2410; border-color: #4a6a2a; } +.control-btn:hover { background-image: image(#4a6a2a); color: #ffffff; } +.control-btn:active { background-image: image(#2a4a1a); color: #ffffff; } + +button.deck-icon-btn { background-image: image(#3a4530); border-color: #4a6a2a; } +button.deck-icon-btn:hover { background-image: image(#4a5a3a); border-color: #6a9a3a; } +button.deck-icon-btn:active { background-image: image(#2a3520); } + +button.global-icon-btn { background-image: image(#3a4530); border-color: #4a6a2a; } +button.global-icon-btn:hover { background-image: image(#4a5a3a); border-color: #6a9a3a; } +button.global-icon-btn:active { background-image: image(#2a3520); } +button.global-icon-btn:checked { background-image: image(#2e7d32); border-color: #4caf50; } + +.ext-sq-btn { background-image: image(#3a4530); color: #ffcc44; border-color: #4a6a2a; } +.ext-sq-btn:hover { background-image: image(#4a6a2a); color: #ffffff; } + +.duck-btn { background-image: image(#e9e4d0); color: #1a2410; border-color: #4a6a2a; } +.duck-btn:checked, .duck-btn:hover { background-image: image(#4a6a2a); color: #ffffff; } + +.list-frame { background-image: image(#1c201a); border-color: #4a5a3a; } +.list-frame > label { background-image: image(#3a4a30); } + +.queue-list row { background-image: image(#1c201a); } +.queue-list row:nth-child(even) { background-image: image(#242822); } +.queue-list row:hover { background-image: image(#2c3226); } + +.playing-row { background-image: image(#3f6f3f); } +button.queue-idx-btn { background-image: image(#1565c0); } diff --git a/assets/skins/red/estilo.css b/assets/skins/red/estilo.css new file mode 100644 index 0000000..b873c0c --- /dev/null +++ b/assets/skins/red/estilo.css @@ -0,0 +1,57 @@ +/* Skin "red" — paleta roja/carmesí sobre negro. Solo colores: mantiene + el layout de G Radio Player intacto. + + NOTA GTK4: los overrides de color de fondo usan background-image: image() + en vez de background-color, porque el tema del sistema puede pisar + background-color pero no background-image (ver LEEME.txt en ejemplo/). */ + +window { background-image: image(#2a0d0d); color: #f0dcdc; } + +.header-bar { background-image: image(#1a0808); border-bottom: 2px solid #4a1e1e; } + +.clock-label { color: #ff6b6b; } +.station-name-lbl { color: #ff8080; } + +.now-playing-bar { background-image: image(#3a1414); } +.now-playing-label { color: #ff6b6b; } + +.version-lbl { color: #b08080; } + +.deck-frame { background-image: image(#2a1414); border: 1px solid #6a2a2a; } +.deck-frame > label { background-image: image(#4a1e1e); color: #ffcccc; } + +.track-title { background-image: image(#8a2a2a); } +.time-label { color: #ff6b6b; } + +scale trough { background-image: image(#3a1a1a); } +scale highlight { background-image: image(#c62828); } +scale slider { background-image: image(#e53935); border-color: #b71c1c; } + +.control-btn { background-image: image(#f0e0e0); color: #3a0d0d; border-color: #b71c1c; } +.control-btn:hover { background-image: image(#b71c1c); color: #ffffff; } +.control-btn:active { background-image: image(#7a1212); color: #ffffff; } + +button.deck-icon-btn { background-image: image(#4a1e1e); border-color: #6a2a2a; } +button.deck-icon-btn:hover { background-image: image(#5a2626); border-color: #9a3a3a; } +button.deck-icon-btn:active { background-image: image(#2a1010); } + +button.global-icon-btn { background-image: image(#4a1e1e); border-color: #6a2a2a; } +button.global-icon-btn:hover { background-image: image(#5a2626); border-color: #9a3a3a; } +button.global-icon-btn:active { background-image: image(#2a1010); } +button.global-icon-btn:checked { background-image: image(#c62828); border-color: #ff6b6b; } + +.ext-sq-btn { background-image: image(#4a1e1e); color: #ff8080; border-color: #6a2a2a; } +.ext-sq-btn:hover { background-image: image(#6a2a2a); color: #ffffff; } + +.duck-btn { background-image: image(#f0e0e0); color: #3a0d0d; border-color: #b71c1c; } +.duck-btn:checked, .duck-btn:hover { background-image: image(#b71c1c); color: #ffffff; } + +.list-frame { background-image: image(#241010); border-color: #6a2a2a; } +.list-frame > label { background-image: image(#4a1e1e); } + +.queue-list row { background-image: image(#241010); } +.queue-list row:nth-child(even) { background-image: image(#2e1414); } +.queue-list row:hover { background-image: image(#3a1818); } + +.playing-row { background-image: image(#8a2a2a); } +button.queue-idx-btn { background-image: image(#c62828); } diff --git a/assets/stop.png b/assets/stop.png new file mode 100755 index 0000000..2b05ff7 Binary files /dev/null and b/assets/stop.png differ diff --git a/assets/trash.png b/assets/trash.png new file mode 100644 index 0000000..686ea0d Binary files /dev/null and b/assets/trash.png differ diff --git a/assets/up.png b/assets/up.png new file mode 100644 index 0000000..a78e722 Binary files /dev/null and b/assets/up.png differ diff --git a/assets/vaciar.png b/assets/vaciar.png new file mode 100644 index 0000000..15b134b Binary files /dev/null and b/assets/vaciar.png differ diff --git a/assets/visor48x48.png b/assets/visor48x48.png new file mode 100644 index 0000000..042b96a Binary files /dev/null and b/assets/visor48x48.png differ diff --git a/build-arch.sh b/build-arch.sh new file mode 100755 index 0000000..06d856e --- /dev/null +++ b/build-arch.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# ─── Compila radio-player para Arch Linux y genera el ZIP ──────────────────── +# Uso: ./build-arch.sh [version] +# Si no se indica versión, la lee de Cargo.toml. +# Requisito: Docker corriendo con imagen gradio-builder-arch construida. +# Primera vez: docker build -f docker/Dockerfile.arch -t gradio-builder-arch docker/ +set -e +cd "$(dirname "$0")" + +IMAGE="gradio-builder-arch" +VERSION="${1:-$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')}" + +# ── Verificar / construir imagen ────────────────────────────────────────────── +if ! docker image inspect "$IMAGE" &>/dev/null; then + echo "==> Imagen '$IMAGE' no encontrada. Construyendo (solo la primera vez)..." + docker build -f docker/Dockerfile.arch -t "$IMAGE" docker/ +fi + +echo "==> Compilando radio-player v${VERSION} para Arch Linux (amd64)..." +echo " Imagen: $IMAGE" +echo "" + +# ── Compilar dentro del contenedor ─────────────────────────────────────────── +# Se monta el fuente como /build y el cache de cargo en un volumen persistente. +docker run --rm \ + -v "$(pwd)":/build \ + -v gradio-cargo-cache-arch:/root/.cargo/registry \ + -v gradio-cargo-git-arch:/root/.cargo/git \ + "$IMAGE" \ + bash -c " + set -e + cd /build + echo '--- cargo build --release ---' + cargo build --release + echo '--- build OK ---' + " + +echo "" +echo "==> Empaquetando ZIP para Arch Linux..." +./package-zip-arch.sh "$VERSION" + +echo "" +echo " Listo. Binarios en target/release/" +echo " ZIP en ../GR-player-RC1-v${VERSION}_amd64_arch.zip" diff --git a/build-rpi3.sh b/build-rpi3.sh new file mode 100755 index 0000000..a72ba97 --- /dev/null +++ b/build-rpi3.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Compila radio-player para Debian Bookworm arm64 usando Docker. +# +# Uso: +# ./build-rpi3.sh # solo compila +# ./build-rpi3.sh --push # compila y copia al host indicado en $RPI3_HOST +# RPI3_HOST=user@host ./build-rpi3.sh --push +# ./build-rpi3.sh --push user@host # equivalente, pasando el host como argumento +set -e +cd "$(dirname "$0")" + +IMAGE="gradio-builder:bookworm" +TARGET="target-bookworm" + +# Host destino para --push. Configurable vía variable de entorno RPI3_HOST +# o como segundo argumento. Sin valor por defecto: el usuario debe definirlo. +RPI3="${2:-${RPI3_HOST:-}}" + +# Usar docker directo si el usuario está en el grupo, sino sudo +if docker info &>/dev/null 2>&1; then + DOCKER="docker" +else + DOCKER="sudo docker" +fi + +# Construir imagen si no existe +if ! $DOCKER image inspect "$IMAGE" &>/dev/null 2>&1; then + echo "==> Construyendo imagen Docker $IMAGE ..." + $DOCKER build -f docker/Dockerfile.bookworm -t "$IMAGE" . + echo "==> Imagen lista." +fi + +echo "==> Compilando para Debian Bookworm arm64 ..." +$DOCKER run --rm \ + -v "$PWD":/build \ + -v gradio-cargo-registry:/root/.cargo/registry \ + -v gradio-cargo-git:/root/.cargo/git \ + -e CARGO_TARGET_DIR=/build/$TARGET \ + "$IMAGE" \ + cargo build --release + +# Corregir ownership si los archivos quedaron como root (sudo docker) +if [ -d "$TARGET" ] && [ "$(stat -c %U "$TARGET")" = "root" ]; then + sudo chown -R "$(whoami)":"$(whoami)" "$TARGET" +fi + +echo "==> Build completo. Binarios en $TARGET/release/" + +# Copiar al host remoto si se pasa --push +if [[ "$1" == "--push" ]]; then + if [[ -z "$RPI3" ]]; then + echo "ERROR: --push requiere el host destino." >&2 + echo " Pásalo como argumento: ./build-rpi3.sh --push user@host" >&2 + echo " O exporta la variable: export RPI3_HOST=user@host" >&2 + exit 1 + fi + echo "==> Copiando binarios a $RPI3 ..." + ssh "$RPI3" "mkdir -p ~/radio-player/target/release" + rsync -av --progress \ + "$TARGET/release/radio-player" \ + "$TARGET/release/comercial-scheduler" \ + "$TARGET/release/playlist-refill" \ + "$TARGET/release/gr-buscador" \ + "$TARGET/release/gr-playlist" \ + "$TARGET/release/gr-pautaje" \ + "$TARGET/release/gr-parrilla" \ + "$TARGET/release/gr-botonera" \ + "$TARGET/release/gr-visor" \ + "$TARGET/release/gr-reportes" \ + "$TARGET/release/gr-record" \ + "$RPI3":~/radio-player/target/release/ 2>/dev/null || true + echo "==> Binarios instalados en RPi3." +fi diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..1f54bec --- /dev/null +++ b/build.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# build.sh - Compila e instala el Radio Player +# Ejecutar en el sistema destino (Linux con GTK4 y GStreamer) + +set -e + +echo "========================================" +echo " G Radio Player - Script de compilación" +echo "========================================" + +# ── Verificar Rust ── +if ! command -v cargo &>/dev/null; then + echo "[!] Rust no encontrado. Instalando via rustup..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" +fi + +echo "[✓] Rust: $(rustc --version)" + +# ── Dependencias del sistema (Ubuntu/Debian) ── +echo "" +echo "[*] Instalando dependencias del sistema..." +sudo apt-get update -q +sudo apt-get install -y \ + pkg-config \ + libgtk-4-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libgstreamer-plugins-bad1.0-dev \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav \ + gstreamer1.0-alsa \ + gstreamer1.0-pulseaudio \ + libssl-dev \ + build-essential + +echo "[✓] Dependencias instaladas" + +# ── Crear directorios de datos si no existen ── +echo "" +echo "[*] Creando estructura de directorios..." +mkdir -p "$HOME/.gradio/data/tmp" +mkdir -p "$HOME/G Radio/inicio-espacio-pub" +mkdir -p "$HOME/G Radio/fin-espacio-pub" +mkdir -p "$HOME/G Radio/comerciales" + +# Crear archivos de configuración por defecto si no existen +[ -f "$HOME/.gradio/data/tmp/upvol" ] || echo "80" > "$HOME/.gradio/data/tmp/upvol" +[ -f "$HOME/.gradio/data/tmp/downvol" ] || echo "20" > "$HOME/.gradio/data/tmp/downvol" +[ -f "$HOME/.gradio/data/mix" ] || echo "3" > "$HOME/.gradio/data/mix" +[ -f "$HOME/.gradio/data/tmp/playlist4" ] || touch "$HOME/.gradio/data/tmp/playlist4" +[ -f "$HOME/.gradio/data/tmp/comercialeslist4" ] || touch "$HOME/.gradio/data/tmp/comercialeslist4" + +echo "[✓] Directorios y archivos de configuración listos" + +# ── Compilar ── +echo "" +echo "[*] Compilando en modo release..." +cd "$(dirname "$0")" +cargo build --release 2>&1 + +echo "" +echo "[✓] Compilación exitosa: target/release/radio-player" + +# ── Instalar (opcional) ── +read -p "[?] ¿Instalar en /usr/local/bin/radio-player? [s/N] " resp +if [[ "$resp" =~ ^[sS]$ ]]; then + sudo cp target/release/radio-player /usr/local/bin/radio-player + sudo chmod +x /usr/local/bin/radio-player + echo "[✓] Instalado en /usr/local/bin/radio-player" +fi + +echo "" +echo "========================================" +echo " Para ejecutar: ./target/release/radio-player" +echo " O si instalaste: radio-player" +echo "========================================" diff --git a/docker/Dockerfile.arch b/docker/Dockerfile.arch new file mode 100644 index 0000000..341c0b2 --- /dev/null +++ b/docker/Dockerfile.arch @@ -0,0 +1,25 @@ +FROM archlinux:latest + +# Actualizar base y herramientas de compilación +RUN pacman -Syu --noconfirm && \ + pacman -S --noconfirm --needed \ + base-devel \ + pkg-config \ + curl \ + ca-certificates \ + gtk4 \ + gstreamer \ + gst-plugins-base \ + gst-plugins-bad \ + gst-plugins-good \ + gst-plugins-ugly \ + gst-libav \ + openssl \ + ffmpeg \ + && pacman -Scc --noconfirm + +# Instalar Rust estable +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +ENV PATH="/root/.cargo/bin:${PATH}" + +WORKDIR /build diff --git a/docker/Dockerfile.bookworm b/docker/Dockerfile.bookworm new file mode 100644 index 0000000..9762691 --- /dev/null +++ b/docker/Dockerfile.bookworm @@ -0,0 +1,22 @@ +FROM debian:bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config build-essential curl ca-certificates \ + libgtk-4-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libgstreamer-plugins-bad1.0-dev \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav \ + gstreamer1.0-alsa \ + gstreamer1.0-pulseaudio \ + libssl-dev \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +ENV PATH="/root/.cargo/bin:${PATH}" + +WORKDIR /build diff --git a/instalar.sh b/instalar.sh new file mode 100755 index 0000000..7eb0ec0 --- /dev/null +++ b/instalar.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# instalar.sh — Instala G Radio Player parcheando el prerm roto de versiones anteriores +set -e +cd "$(dirname "$0")" + +ARCH="$(dpkg --print-architecture 2>/dev/null || uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" +DEB="" + +# Detectar el .deb para esta arquitectura +for candidate in \ + "gradio-player_"*"_${ARCH}.deb" \ + "gradio-player_"*"_${ARCH}_bookworm.deb"; do + # shellcheck disable=SC2144 + if ls $candidate 2>/dev/null | head -1 | grep -q .; then + DEB="$(ls $candidate 2>/dev/null | sort -V | tail -1)" + break + fi +done + +if [ -z "$DEB" ]; then + echo "ERROR: No se encontró ningún .deb para arquitectura '$ARCH' en el directorio actual." + echo " Ejecuta este script desde la carpeta que contiene el .deb." + exit 1 +fi + +echo "==> Instalando: $DEB" + +# Parchear prerm y postrm instalados si tienen bugs históricos +PRERM="/var/lib/dpkg/info/gradio-player.prerm" +POSTRM="/var/lib/dpkg/info/gradio-player.postrm" + +if [ -f "$PRERM" ] && grep -q "pkill" "$PRERM"; then + echo "==> Parcheando prerm instalado (contiene pkill — bug conocido)..." + printf '#!/bin/bash\nexit 0\n' | sudo tee "$PRERM" > /dev/null +fi + +if [ -f "$POSTRM" ] && ! grep -q 'case "\$1"' "$POSTRM"; then + echo "==> Parcheando postrm instalado (borra archivos en upgrade — bug conocido)..." + sudo tee "$POSTRM" > /dev/null << 'POSTRM_FIX' +#!/bin/bash +case "$1" in + remove|purge) + for BIN in radio-player comercial-scheduler playlist-refill \ + gradio.sh gr-buscador gr-playlist gr-pautaje gr-parrilla \ + gr-botonera gr-visor gr-reportes gr-record; do + rm -f "/usr/local/bin/$BIN" + done + rm -rf /usr/local/share/radio-player + rm -f /usr/share/applications/com.gradio.radio-player.desktop + rm -f /usr/share/applications/gradio-player.desktop + rm -f /usr/share/icons/hicolor/48x48/apps/gradio-player.png + command -v gtk-update-icon-cache &>/dev/null && gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true + command -v update-desktop-database &>/dev/null && update-desktop-database /usr/share/applications 2>/dev/null || true + ;; +esac +POSTRM_FIX + sudo chmod 755 "$POSTRM" +fi + +sudo dpkg -i "$DEB" + +echo "" +echo "✅ Instalación completa. Ejecutar: gradio.sh" diff --git a/install-arch.sh b/install-arch.sh new file mode 100755 index 0000000..1bf6438 --- /dev/null +++ b/install-arch.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# ─── G Radio Player — instalador para Arch Linux ───────────────────────────── +set -e +cd "$(dirname "$0")" + +INSTALL_DIR="$HOME/.local/share/radio-player" +BIN_DIR="$HOME/.local/bin" +DESKTOP_DIR="$HOME/.local/share/applications" + +# ── 1. Dependencias del sistema (pacman) ────────────────────────────────────── +echo "==> Verificando dependencias..." + +PKGS=( + gstreamer + gst-plugins-base + gst-plugins-good + gst-plugins-bad + gst-plugins-ugly + gst-libav + ffmpeg +) + +MISSING=() +for pkg in "${PKGS[@]}"; do + if ! pacman -Q "$pkg" &>/dev/null; then + MISSING+=("$pkg") + fi +done + +if [ ${#MISSING[@]} -gt 0 ]; then + echo " Faltan paquetes: ${MISSING[*]}" + echo " Instalando con pacman..." + sudo pacman -S --needed --noconfirm "${MISSING[@]}" +fi + +# ── 2. Instalar binarios ────────────────────────────────────────────────────── +echo "==> Instalando binarios en $BIN_DIR..." +BINS=( + radio-player + comercial-scheduler + playlist-refill + gr-visor + gr-botonera + gr-buscador + gr-parrilla + gr-pautaje + gr-playlist + gr-record + gr-reportes +) +for bin in "${BINS[@]}"; do + if [ ! -f "target/release/$bin" ]; then + echo "ERROR: binario no encontrado: target/release/$bin" + echo " Compila el proyecto antes de instalar (cargo build --release)" + exit 1 + fi +done +mkdir -p "$BIN_DIR" +for bin in "${BINS[@]}"; do + cp "target/release/$bin" "$BIN_DIR/$bin" + chmod +x "$BIN_DIR/$bin" +done + +# ── 3. Instalar assets ──────────────────────────────────────────────────────── +echo "==> Instalando assets..." +if [ ! -d "target/data/panel/Time" ]; then + echo "ERROR: carpeta 'target/data/panel/Time' no encontrada en el paquete" + exit 1 +fi +mkdir -p "$HOME/.gradio/data/panel" +cp -r target/data/panel/Time "$HOME/.gradio/data/panel/Time" + +# ── 4. Crear lanzador principal ─────────────────────────────────────────────── +echo "==> Creando lanzador..." +mkdir -p "$INSTALL_DIR" +cat > "$INSTALL_DIR/gradio.sh" << 'LAUNCHER' +#!/bin/bash +BIN="$HOME/.local/bin" + +mkdir -p "$HOME/.gradio/data/tmp" +mkdir -p "$HOME/.gradio/data/parrilla" +mkdir -p "$HOME/.gradio/data/comerciales" +mkdir -p "$HOME/.gradio/data/eventos-espera" +mkdir -p "$HOME/.gradio/data/eventos" +mkdir -p "$HOME/.gradio/data/reporte" +mkdir -p "$HOME/G Radio/inicio-espacio-pub" +mkdir -p "$HOME/G Radio/fin-espacio-pub" + +"$BIN/comercial-scheduler" & +SCHED_PID=$! +"$BIN/playlist-refill" & +REFILL_PID=$! + +"$BIN/radio-player" "$@" +EXIT_CODE=$? + +kill $SCHED_PID $REFILL_PID 2>/dev/null +wait $SCHED_PID $REFILL_PID 2>/dev/null +exit $EXIT_CODE +LAUNCHER +chmod +x "$INSTALL_DIR/gradio.sh" +ln -sf "$INSTALL_DIR/gradio.sh" "$BIN_DIR/gradio" + +# ── 5. Entrada de menú de escritorio ────────────────────────────────────────── +echo "==> Registrando en el menú de aplicaciones..." +mkdir -p "$DESKTOP_DIR" +# Limpiar entrada de versiones antiguas (nombre no coincidía con el app_id) +rm -f "$DESKTOP_DIR/gradio-player.desktop" +cat > "$DESKTOP_DIR/com.gradio.radio-player.desktop" << DESKTOP +[Desktop Entry] +Type=Application +Name=G Radio Player +GenericName=Radio Player +Comment=Reproductor de radio profesional +Exec=$INSTALL_DIR/gradio.sh +Icon=gradio-player +Categories=AudioVideo;Audio;Player; +Terminal=false +StartupNotify=true +DESKTOP + +update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true + +# ── 6. 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: gradio" +echo " Desinstalar: ./uninstall.sh" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7acf96e --- /dev/null +++ b/install.sh @@ -0,0 +1,221 @@ +#!/bin/bash +# ─── G Radio Player — instalador del sistema ───────────────────────────────── +set -e +cd "$(dirname "$0")" + +INSTALL_DIR="$HOME/.local/share/radio-player" +BIN_DIR="$HOME/.local/bin" +DESKTOP_DIR="$HOME/.local/share/applications" + +# ── 1. Verificar dependencias del sistema ───────────────────────────────────── +echo "==> Verificando dependencias..." +MISSING=() + +check() { + if ! dpkg -l "$1" &>/dev/null 2>&1 && ! rpm -q "$1" &>/dev/null 2>&1; then + MISSING+=("$1") + fi +} + +# Detectar gestor de paquetes +if command -v apt &>/dev/null; then + PKG_MGR="apt" + PKGS=( + gstreamer1.0-plugins-base + gstreamer1.0-plugins-good + gstreamer1.0-plugins-bad + gstreamer1.0-plugins-ugly + gstreamer1.0-libav + gstreamer1.0-pulseaudio + gstreamer1.0-alsa + libgstreamer1.0-0 + libgstreamer-plugins-base1.0-0 + ffmpeg + ) + MISSING_CHECK=$(dpkg -l "${PKGS[@]}" 2>&1 | grep "^un\|no packages" | awk '{print $2}' || true) +elif command -v dnf &>/dev/null; then + PKG_MGR="dnf" + PKGS=( + gstreamer1-plugins-base + gstreamer1-plugins-good + gstreamer1-plugins-bad-free + gstreamer1-plugins-ugly-free + gstreamer1-libav + ffmpeg + ) +else + PKG_MGR="unknown" + PKGS=() +fi + +if [ ${#PKGS[@]} -gt 0 ] && [ -n "$MISSING_CHECK" ]; then + echo "" + echo " Faltan dependencias GStreamer. Instalando..." + if [ "$PKG_MGR" = "apt" ]; then + sudo apt install -y "${PKGS[@]}" + elif [ "$PKG_MGR" = "dnf" ]; then + sudo dnf install -y "${PKGS[@]}" + fi +fi + +# Verificar ffmpeg/ffprobe +if ! command -v ffprobe &>/dev/null; then + echo " ffprobe no encontrado. Instalando ffmpeg..." + if [ "$PKG_MGR" = "apt" ]; then + sudo apt install -y ffmpeg + elif [ "$PKG_MGR" = "dnf" ]; then + sudo dnf install -y ffmpeg + else + echo " AVISO: instala ffmpeg manualmente (necesario para playlist-refill)" + fi +fi + +# ── 2. Instalar binarios ────────────────────────────────────────────────────── +echo "==> Instalando binarios en $BIN_DIR..." +BINS=( + radio-player + comercial-scheduler + playlist-refill + gr-visor + gr-botonera + gr-buscador + gr-parrilla + gr-pautaje + gr-playlist + gr-record + gr-reportes +) +for bin in "${BINS[@]}"; do + if [ ! -f "target/release/$bin" ]; then + echo "ERROR: binario no encontrado: target/release/$bin" + echo " Compila el proyecto antes de instalar (cargo build --release)" + exit 1 + fi +done +mkdir -p "$BIN_DIR" +for bin in "${BINS[@]}"; do + cp "target/release/$bin" "$BIN_DIR/$bin" + chmod +x "$BIN_DIR/$bin" +done + +# ── 3. Instalar assets ─────────────────────────────────────────────────────── +echo "==> Instalando assets..." +if [ -d "assets/Time" ]; then + TIME_SRC="assets/Time" +elif [ -d "target/data/panel/Time" ]; then + TIME_SRC="target/data/panel/Time" +else + echo "ERROR: carpeta 'Time' no encontrada (se buscó en assets/Time y target/data/panel/Time)" + exit 1 +fi +mkdir -p "$HOME/.gradio/data/panel" +cp -r "$TIME_SRC" "$HOME/.gradio/data/panel/Time" + +# ── Instalar contexto de IA (opencode) ─────────────────────────────────────── +OPENCODE_CTX_SRC="" +if [ -d "assets/opencode" ]; then + OPENCODE_CTX_SRC="assets/opencode" +elif [ -d "target/data/opencode" ]; then + OPENCODE_CTX_SRC="target/data/opencode" +fi +if [ -n "$OPENCODE_CTX_SRC" ]; then + echo "==> Instalando contexto IA en ~/.gradio/data/opencode/..." + mkdir -p "$HOME/.gradio/data/opencode/agents" + mkdir -p "$HOME/.gradio/data/opencode/context" + cp -r "$OPENCODE_CTX_SRC/"* "$HOME/.gradio/data/opencode/" 2>/dev/null || true +fi + +# ── Instalar skins de ejemplo ────────────────────────────────────────────────── +# Solo copia skins que aún no existan en destino, para no pisar los que el +# usuario haya instalado/personalizado en una reinstalación o actualización. +SKINS_SRC="" +if [ -d "assets/skins" ]; then + SKINS_SRC="assets/skins" +elif [ -d "target/data/skins" ]; then + SKINS_SRC="target/data/skins" +fi +if [ -n "$SKINS_SRC" ]; then + echo "==> Instalando skins de ejemplo en ~/.gradio/data/skins/..." + mkdir -p "$HOME/.gradio/data/skins" + for skin_dir in "$SKINS_SRC"/*/; do + [ -d "$skin_dir" ] || continue + skin_name=$(basename "$skin_dir") + if [ ! -d "$HOME/.gradio/data/skins/$skin_name" ]; then + cp -r "$skin_dir" "$HOME/.gradio/data/skins/$skin_name" + fi + done +fi + +# ── 4. Crear lanzador principal ─────────────────────────────────────────────── +echo "==> Creando lanzador..." +mkdir -p "$INSTALL_DIR" +cat > "$INSTALL_DIR/gradio.sh" << 'LAUNCHER' +#!/bin/bash +BIN="$HOME/.local/bin" + +# Crear directorios de configuración +mkdir -p "$HOME/.gradio/data/tmp" +mkdir -p "$HOME/.gradio/data/parrilla" +mkdir -p "$HOME/.gradio/data/comerciales" +mkdir -p "$HOME/.gradio/data/eventos-espera" +mkdir -p "$HOME/.gradio/data/eventos" +mkdir -p "$HOME/.gradio/data/reporte" +mkdir -p "$HOME/G Radio/inicio-espacio-pub" +mkdir -p "$HOME/G Radio/fin-espacio-pub" + +# Iniciar auxiliares +"$BIN/comercial-scheduler" & +SCHED_PID=$! +"$BIN/playlist-refill" & +REFILL_PID=$! + +# Iniciar UI +"$BIN/radio-player" "$@" +EXIT_CODE=$? + +# Apagar auxiliares +kill $SCHED_PID $REFILL_PID 2>/dev/null +wait $SCHED_PID $REFILL_PID 2>/dev/null +exit $EXIT_CODE +LAUNCHER +chmod +x "$INSTALL_DIR/gradio.sh" +ln -sf "$INSTALL_DIR/gradio.sh" "$BIN_DIR/gradio" + +# ── 5. Entrada de menú de escritorio ───────────────────────────────────────── +echo "==> Registrando en el menú de aplicaciones..." +mkdir -p "$DESKTOP_DIR" +# Limpiar entrada de versiones antiguas (nombre no coincidía con el app_id) +rm -f "$DESKTOP_DIR/gradio-player.desktop" +cat > "$DESKTOP_DIR/com.gradio.radio-player.desktop" << DESKTOP +[Desktop Entry] +Type=Application +Name=G Radio Player +GenericName=Radio Player +Comment=Reproductor de radio profesional +Exec=$INSTALL_DIR/gradio.sh +Icon=gradio-player +Categories=AudioVideo;Audio;Player; +Terminal=false +StartupNotify=true +DESKTOP + +# Actualizar base de datos de aplicaciones +update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true + +# ── 6. Agregar ~/.local/bin al PATH si no está ──────────────────────────────── +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 desde terminal: $INSTALL_DIR/gradio.sh" +echo " Ejecutar desde el menú: busca 'G Radio Player'" +echo "" +echo " Para desinstalar: ./uninstall.sh" diff --git a/package-deb.sh b/package-deb.sh new file mode 100755 index 0000000..b7ed2eb --- /dev/null +++ b/package-deb.sh @@ -0,0 +1,405 @@ +#!/bin/bash +# package-deb.sh — Empaqueta G Radio Player como .deb instalable +# Uso: ./package-deb.sh [--target-dir ] [--suffix ] +# --target-dir directorio de binarios (default: target/release) +# --suffix sufijo al nombre del paquete (ej: _bookworm) +set -e + +VERSION="${1:-0.2.8}" +TARGET_DIR="target/release" +SUFFIX="" + +shift || true +while [[ $# -gt 0 ]]; do + case "$1" in + --target-dir) TARGET_DIR="$2"; shift 2 ;; + --suffix) SUFFIX="$2"; shift 2 ;; + *) shift ;; + esac +done + +ARCH="$(dpkg --print-architecture)" +PKG_NAME="gradio-player" +PKG_DIR="${PKG_NAME}_${VERSION}_${ARCH}${SUFFIX}" +ROOT="$(cd "$(dirname "$0")" && pwd)" + +# ── Verificar que los binarios existen ──────────────────────────────────────── +for BIN in radio-player comercial-scheduler playlist-refill \ + gr-buscador gr-playlist gr-pautaje gr-parrilla \ + gr-botonera gr-visor gr-reportes gr-record; do + if [ ! -f "$ROOT/$TARGET_DIR/$BIN" ]; then + echo "[!] Binario faltante: $TARGET_DIR/$BIN" + echo " Ejecuta primero: cargo build --release" + exit 1 + fi +done + +echo "========================================" +echo " Empaquetando $PKG_NAME $VERSION ($ARCH)" +echo "========================================" + +# ── Limpiar directorio anterior ─────────────────────────────────────────────── +rm -rf "$PKG_DIR" + +# ── Verificar que existe la carpeta Time ────────────────────────────────────── +if [ -d "$ROOT/assets/Time" ]; then + TIME_SRC="$ROOT/assets/Time" +elif [ -d "$ROOT/target/data/panel/Time" ]; then + TIME_SRC="$ROOT/target/data/panel/Time" +else + echo "[!] Carpeta Time no encontrada (assets/Time ni target/data/panel/Time)" + exit 1 +fi + +# ── Estructura de directorios ───────────────────────────────────────────────── +mkdir -p "$PKG_DIR/DEBIAN" +mkdir -p "$PKG_DIR/usr/local/bin" +mkdir -p "$PKG_DIR/usr/local/share/radio-player/Time" +mkdir -p "$PKG_DIR/usr/local/share/radio-player/opencode/agents" +mkdir -p "$PKG_DIR/usr/local/share/radio-player/opencode/context" +mkdir -p "$PKG_DIR/usr/local/share/radio-player/skins" +mkdir -p "$PKG_DIR/usr/share/applications" +mkdir -p "$PKG_DIR/usr/share/icons/hicolor/48x48/apps" + +# ── Copiar archivos de hora (Time) ─────────────────────────────────────────── +echo "[*] Copiando carpeta Time..." +cp "$TIME_SRC/"* "$PKG_DIR/usr/local/share/radio-player/Time/" + +# ── Copiar contexto de IA (opencode) ───────────────────────────────────────── +if [ -d "$ROOT/assets/opencode" ]; then + echo "[*] Copiando contexto IA (opencode)..." + cp "$ROOT/assets/opencode/AGENTS.md" "$PKG_DIR/usr/local/share/radio-player/opencode/" + cp "$ROOT/assets/opencode/opencode.jsonc" "$PKG_DIR/usr/local/share/radio-player/opencode/" 2>/dev/null || true + cp "$ROOT/assets/opencode/agents/"* "$PKG_DIR/usr/local/share/radio-player/opencode/agents/" + cp "$ROOT/assets/opencode/context/"* "$PKG_DIR/usr/local/share/radio-player/opencode/context/" +fi + +# ── Copiar skins de ejemplo ─────────────────────────────────────────────────── +if [ -d "$ROOT/assets/skins" ]; then + echo "[*] Copiando skins de ejemplo..." + cp -r "$ROOT/assets/skins/"* "$PKG_DIR/usr/local/share/radio-player/skins/" 2>/dev/null || true +fi + +# ── Copiar binarios ─────────────────────────────────────────────────────────── +echo "[*] Copiando binarios..." +for BIN in radio-player comercial-scheduler playlist-refill \ + gr-buscador gr-playlist gr-pautaje gr-parrilla \ + gr-botonera gr-visor gr-reportes gr-record; do + cp "$ROOT/$TARGET_DIR/$BIN" "$PKG_DIR/usr/local/bin/$BIN" + chmod 755 "$PKG_DIR/usr/local/bin/$BIN" +done + +# ── Lanzador principal ──────────────────────────────────────────────────────── +echo "[*] Creando lanzador gradio.sh..." +cat > "$PKG_DIR/usr/local/bin/gradio.sh" << 'LAUNCHER' +#!/bin/bash +# Lanzador de G Radio Player — equivalente al run.sh del proyecto fuente + +BIN="/usr/local/bin" +HOME_GRADIO="$HOME/.gradio/data" +PIDDIR="$HOME_GRADIO/tmp/pids" + +mkdir -p "$PIDDIR" + +# URL del relay internet (usada por relay.rs si "relay habilitado" está activo +# en Configuración). Sin esto, relay.rs cae al placeholder wss://relay.example.com/ws +# y jamás llega a registrarse — la conexión remota vía Internet queda rota en silencio. +export GRADIO_RELAY_URL="${GRADIO_RELAY_URL:-wss://relay.gradio.net/ws}" + +# ── Matar proceso por PID guardado + búsqueda por nombre ───────────────────── +kill_aux() { + local name="$1" + local pidfile="$PIDDIR/$name.pid" + + if [ -f "$pidfile" ]; then + local pid + pid=$(cat "$pidfile" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo " Deteniendo $name (PID $pid)..." + kill "$pid" 2>/dev/null + for i in $(seq 1 6); do + sleep 0.5 + kill -0 "$pid" 2>/dev/null || break + done + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null + fi + rm -f "$pidfile" + fi + + pkill -x "$name" 2>/dev/null + sleep 0.3 +} + +# ── Matar instancias previas ────────────────────────────────────────────────── +echo "==> Deteniendo instancias previas (si las hay)..." +kill_aux "comercial-scheduler" +kill_aux "playlist-refill" +sleep 0.5 + +# ── Crear estructura de directorios ────────────────────────────────────────── +mkdir -p "$HOME_GRADIO/tmp" +mkdir -p "$HOME_GRADIO/comerciales" +mkdir -p "$HOME_GRADIO/eventos-espera" +mkdir -p "$HOME_GRADIO/eventos" +mkdir -p "$HOME_GRADIO/parrilla" +mkdir -p "$HOME_GRADIO/reporte" +mkdir -p "$HOME_GRADIO/panel" +mkdir -p "$HOME/G Radio/inicio-espacio-pub" +mkdir -p "$HOME/G Radio/fin-espacio-pub" +mkdir -p "$HOME/G Radio/comerciales" + +# ── Instalar carpeta Time (audios de hora) si aún no existe ────────────────── +if [ ! -d "$HOME_GRADIO/panel/Time" ]; then + echo "==> Instalando carpeta Time en $HOME_GRADIO/panel/..." + cp -r /usr/local/share/radio-player/Time "$HOME_GRADIO/panel/Time" +fi + +# ── Instalar contexto IA (opencode) si no existe o está desactualizado ─────── +OPENCODE_SRC="/usr/local/share/radio-player/opencode" +OPENCODE_DST="$HOME_GRADIO/opencode" +if [ -d "$OPENCODE_SRC" ] && [ ! -f "$OPENCODE_DST/AGENTS.md" ]; then + echo "==> Instalando contexto IA en $OPENCODE_DST/..." + mkdir -p "$OPENCODE_DST/agents" "$OPENCODE_DST/context" + cp -r "$OPENCODE_SRC/"* "$OPENCODE_DST/" +fi + +# ── Instalar skins de ejemplo (solo los que aún no existan) ────────────────── +SKINS_SRC="/usr/local/share/radio-player/skins" +SKINS_DST="$HOME_GRADIO/skins" +if [ -d "$SKINS_SRC" ]; then + mkdir -p "$SKINS_DST" + for skin_dir in "$SKINS_SRC"/*/; do + [ -d "$skin_dir" ] || continue + skin_name=$(basename "$skin_dir") + if [ ! -d "$SKINS_DST/$skin_name" ]; then + echo "==> Instalando skin '$skin_name' en $SKINS_DST/..." + cp -r "$skin_dir" "$SKINS_DST/$skin_name" + fi + done +fi + +# ── Migración device → gradio.config (compatibilidad con versiones anteriores) +DEVICE_FILE="$HOME_GRADIO/tmp/device" +CONFIG_FILE="$HOME_GRADIO/tmp/gradio.config" +if [ -f "$DEVICE_FILE" ] && [ ! -f "$CONFIG_FILE" ]; then + echo "==> Migrando device → gradio.config..." + MIX=$(cat "$HOME_GRADIO/mix" 2>/dev/null || echo "3.0") + paste -d"\n" "$DEVICE_FILE" /dev/null > "$CONFIG_FILE" + printf "G Radio\n%s\n" "$MIX" >> "$CONFIG_FILE" + rm -f "$DEVICE_FILE" +fi + +# ── Archivos de configuración por defecto ───────────────────────────────────── +[ -f "$HOME_GRADIO/tmp/upvol" ] || echo "90" > "$HOME_GRADIO/tmp/upvol" +[ -f "$HOME_GRADIO/tmp/downvol" ] || echo "20" > "$HOME_GRADIO/tmp/downvol" +[ -f "$HOME_GRADIO/mix" ] || echo "3" > "$HOME_GRADIO/mix" + +# ── Limpiar listas temporales (toma la programación de la hora actual) ──────── +> "$HOME_GRADIO/tmp/comercialeslist4" +> "$HOME_GRADIO/tmp/eventos-esperalist" +> "$HOME_GRADIO/tmp/eventoslist" +> "$HOME_GRADIO/tmp/playlist4" +echo "==> Listas temporales limpiadas." + +# ── Iniciar daemons auxiliares ──────────────────────────────────────────────── +echo "==> Iniciando comercial-scheduler..." +"$BIN/comercial-scheduler" & +SCHED_PID=$! +echo "$SCHED_PID" > "$PIDDIR/comercial-scheduler.pid" +echo " PID: $SCHED_PID" + +echo "==> Iniciando playlist-refill..." +"$BIN/playlist-refill" & +REFILL_PID=$! +echo "$REFILL_PID" > "$PIDDIR/playlist-refill.pid" +echo " PID: $REFILL_PID" + +# ── Watchdog: verifica cada 30s que los daemons siguen vivos ────────────────── +watchdog() { + while true; do + sleep 30 + kill -0 "$RADIO_PID" 2>/dev/null || break + + if ! kill -0 "$SCHED_PID" 2>/dev/null; then + echo "==> WATCHDOG: comercial-scheduler caído, reiniciando..." + "$BIN/comercial-scheduler" & + SCHED_PID=$! + echo "$SCHED_PID" > "$PIDDIR/comercial-scheduler.pid" + fi + + if ! kill -0 "$REFILL_PID" 2>/dev/null; then + echo "==> WATCHDOG: playlist-refill caído, reiniciando..." + "$BIN/playlist-refill" & + REFILL_PID=$! + echo "$REFILL_PID" > "$PIDDIR/playlist-refill.pid" + fi + done +} + +# ── Inhibir suspensión / DPMS (ARM: previene congelamiento del main loop GTK4) ── +if command -v systemd-inhibit &>/dev/null; then + echo "==> Inhibiendo idle con systemd-inhibit..." + RUST_LOG=info systemd-inhibit \ + --what=idle \ + --who="G Radio Player" \ + --why="Reproducción de audio en curso" \ + --mode=block \ + "$BIN/radio-player" "$@" & + RADIO_PID=$! +else + xset s off 2>/dev/null || true + xset -dpms 2>/dev/null || true + xset s noblank 2>/dev/null || true + echo "==> Iniciando radio-player..." + RUST_LOG=info "$BIN/radio-player" "$@" & + RADIO_PID=$! +fi + +watchdog & +WATCHDOG_PID=$! + +wait "$RADIO_PID" + +# ── Limpieza al salir ───────────────────────────────────────────────────────── +echo "==> Cerrando procesos auxiliares..." +kill "$WATCHDOG_PID" 2>/dev/null +kill_aux "comercial-scheduler" +kill_aux "playlist-refill" +rm -f "$PIDDIR/comercial-scheduler.pid" "$PIDDIR/playlist-refill.pid" +echo "==> Terminado." +LAUNCHER +chmod 755 "$PKG_DIR/usr/local/bin/gradio.sh" + +# ── Icono ───────────────────────────────────────────────────────────────────── +if [ -f "$ROOT/assets/gradio.png" ]; then + cp "$ROOT/assets/gradio.png" "$PKG_DIR/usr/share/icons/hicolor/48x48/apps/gradio-player.png" + chmod 644 "$PKG_DIR/usr/share/icons/hicolor/48x48/apps/gradio-player.png" +fi + +# ── Entrada de escritorio ───────────────────────────────────────────────────── +cat > "$PKG_DIR/usr/share/applications/com.gradio.radio-player.desktop" << DESKTOP +[Desktop Entry] +Type=Application +Name=G Radio Player +GenericName=Radio Automation +Comment=Sistema de automatización de radio profesional +Exec=/usr/local/bin/gradio.sh +Icon=gradio-player +Categories=AudioVideo;Audio;Player; +Terminal=false +StartupNotify=true +Keywords=radio;audio;player;automation; +DESKTOP + +# ── DEBIAN/control ──────────────────────────────────────────────────────────── +echo "[*] Generando DEBIAN/control..." +INSTALLED_SIZE=$(du -sk "$PKG_DIR/usr" | cut -f1) + +cat > "$PKG_DIR/DEBIAN/control" << CONTROL +Package: gradio-player +Version: ${VERSION} +Architecture: ${ARCH} +Maintainer: G Radio +Installed-Size: ${INSTALLED_SIZE} +Depends: libgtk-4-1 (>= 4.6), + libgstreamer1.0-0 (>= 1.20), + libgstreamer-plugins-base1.0-0 (>= 1.20), + gstreamer1.0-plugins-base (>= 1.20), + gstreamer1.0-plugins-good (>= 1.20), + gstreamer1.0-plugins-bad (>= 1.20), + gstreamer1.0-plugins-ugly (>= 1.20), + gstreamer1.0-libav (>= 1.20), + gstreamer1.0-alsa | gstreamer1.0-pulseaudio, + ffmpeg, + libc6 (>= 2.17) +Section: sound +Priority: optional +Description: G Radio Player — sistema de automatización de radio + Reproductor profesional para emisoras de radio con programación automática + de música, comerciales y eventos. Incluye interfaz GTK4, crossfading, + duck de volumen y gestión de parrilla horaria. + . + Componentes: radio-player, comercial-scheduler, playlist-refill, + gr-pautaje, gr-parrilla, gr-botonera, gr-visor, gr-buscador, gr-playlist, + gr-record. +CONTROL + +# ── DEBIAN/postinst ─────────────────────────────────────────────────────────── +cat > "$PKG_DIR/DEBIAN/postinst" << 'POSTINST' +#!/bin/bash +set -e + +# Limpiar entrada de escritorio de versiones antiguas (renombrada a +# com.gradio.radio-player.desktop para que el app_id coincida con el nombre +# del .desktop — requerido para que Wayland resuelva el ícono correctamente). +rm -f /usr/share/applications/gradio-player.desktop + +# Actualizar caché de iconos y menú de escritorio +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 Player instalado correctamente." +echo "Ejecutar: gradio.sh o buscar 'G Radio Player' en el menú." +POSTINST +chmod 755 "$PKG_DIR/DEBIAN/postinst" + +# ── DEBIAN/prerm ────────────────────────────────────────────────────────────── +# No matamos procesos aquí: Linux mantiene el ejecutable anterior en memoria +# hasta que cierre (safe inode replacement). Tener pkill en prerm rompe dpkg +# al actualizar porque el prerm de la versión INSTALADA corre primero — y si +# esa versión tenía pkill -f (bug histórico), mata al propio dpkg. +cat > "$PKG_DIR/DEBIAN/prerm" << 'PRERM' +#!/bin/bash +exit 0 +PRERM +chmod 755 "$PKG_DIR/DEBIAN/prerm" + +# ── DEBIAN/postrm ───────────────────────────────────────────────────────────── +cat > "$PKG_DIR/DEBIAN/postrm" << 'POSTRM' +#!/bin/bash +# Solo borrar archivos en remove/purge — nunca en upgrade/failed-upgrade. +# Durante un upgrade dpkg instala el nuevo paquete ANTES de correr este +# postrm; si borramos incondicionalmente, eliminamos los binarios recién +# instalados por la versión nueva. +case "$1" in + remove|purge) + for BIN in radio-player comercial-scheduler playlist-refill \ + gradio.sh gr-buscador gr-playlist gr-pautaje gr-parrilla \ + gr-botonera gr-visor gr-reportes gr-record; do + rm -f "/usr/local/bin/$BIN" + done + rm -rf /usr/local/share/radio-player + rm -f /usr/share/applications/com.gradio.radio-player.desktop + rm -f /usr/share/applications/gradio-player.desktop # limpieza de versiones antiguas + rm -f /usr/share/icons/hicolor/48x48/apps/gradio-player.png + command -v gtk-update-icon-cache &>/dev/null && \ + gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true + command -v update-desktop-database &>/dev/null && \ + update-desktop-database /usr/share/applications 2>/dev/null || true + ;; +esac +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 gradio-player" +echo "" diff --git a/package-zip-arch.sh b/package-zip-arch.sh new file mode 100755 index 0000000..131456a --- /dev/null +++ b/package-zip-arch.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# ─── Empaqueta binarios amd64 en ZIP para Arch Linux ───────────────────────── +# Uso: ./package-zip-arch.sh +# Genera: GR-player-RC1-v_amd64_arch.zip (en el directorio padre) +set -e + +VERSION="${1:?Uso: $0 ej: $0 0.4.6}" +ZNAME="GR-player-RC1-v${VERSION}_amd64_arch" +ZDIR="/tmp/${ZNAME}" +DEST="$(dirname "$0")/../${ZNAME}.zip" + +cd "$(dirname "$0")" + +# ── Verificar binarios ──────────────────────────────────────────────────────── +BINS=( + radio-player + comercial-scheduler + playlist-refill + gr-visor + gr-botonera + gr-buscador + gr-parrilla + gr-pautaje + gr-playlist + gr-record + gr-reportes +) +for bin in "${BINS[@]}"; do + if [ ! -f "target/release/$bin" ]; then + echo "ERROR: binario no encontrado: target/release/$bin" + echo " Ejecuta 'cargo build --release' primero." + exit 1 + fi +done + +# Assets en el directorio padre (fuera de git/) +ASSETS_DIR="../target/data/panel/Time" +if [ ! -d "$ASSETS_DIR" ]; then + echo "ERROR: carpeta 'target/data/panel/Time' no encontrada en el directorio padre." + exit 1 +fi + +# ── Armar estructura del ZIP ────────────────────────────────────────────────── +rm -rf "$ZDIR" +mkdir -p "$ZDIR/target/release" +mkdir -p "$ZDIR/target/data/panel" + +for bin in "${BINS[@]}"; do + cp "target/release/$bin" "$ZDIR/target/release/$bin" +done + +cp -r "$ASSETS_DIR" "$ZDIR/target/data/panel/Time" +cp install-arch.sh "$ZDIR/install.sh" +cp uninstall.sh "$ZDIR/uninstall.sh" +chmod +x "$ZDIR/install.sh" "$ZDIR/uninstall.sh" + +# ── Crear ZIP ───────────────────────────────────────────────────────────────── +rm -f "$DEST" +(cd /tmp && zip -r "$OLDPWD/$DEST" "$ZNAME") +rm -rf "$ZDIR" + +echo "" +echo " ZIP generado: $(realpath "$DEST")" +echo " Versión: $VERSION | Arch Linux amd64" diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..63f7610 --- /dev/null +++ b/run.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# ─── G Radio Player — lanzador completo ───────────────────────────────────── + +cd "$(dirname "$0")" +BIN="./target/release" +HOME_GRADIO="$HOME/.gradio/data" +PIDDIR="$HOME_GRADIO/tmp/pids" + +# URL del relay internet (ver nota equivalente en package-deb.sh) +export GRADIO_RELAY_URL="${GRADIO_RELAY_URL:-wss://relay.gradio.net/ws}" + +# Verificar que estén compilados +for b in radio-player comercial-scheduler playlist-refill; do + if [ ! -f "$BIN/$b" ]; then + echo "ERROR: $BIN/$b no existe. Ejecuta ./build.sh primero." + exit 1 + fi +done + +mkdir -p "$PIDDIR" + +# ── Matar proceso por archivo PID + búsqueda por nombre ────────────────────── +kill_aux() { + local name="$1" + local pidfile="$PIDDIR/$name.pid" + + # Matar por PID guardado + if [ -f "$pidfile" ]; then + local pid + pid=$(cat "$pidfile" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo " Deteniendo $name (PID $pid)..." + kill "$pid" 2>/dev/null + for i in $(seq 1 6); do + sleep 0.5 + kill -0 "$pid" 2>/dev/null || break + done + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null + fi + rm -f "$pidfile" + fi + + # Matar cualquier instancia adicional por nombre (por si hay huérfanos) + pkill -f "target/release/$name" 2>/dev/null + sleep 0.3 +} + +# ── Matar instancias previas ────────────────────────────────────────────────── +echo "==> Deteniendo instancias previas (si las hay)..." +kill_aux "comercial-scheduler" +kill_aux "playlist-refill" +sleep 0.5 + +# ── Crear directorios necesarios ────────────────────────────────────────────── +mkdir -p "$HOME_GRADIO/tmp" +mkdir -p "$HOME_GRADIO/comerciales" +mkdir -p "$HOME_GRADIO/eventos-espera" +mkdir -p "$HOME_GRADIO/eventos" +mkdir -p "$HOME_GRADIO/parrilla" +mkdir -p "$HOME_GRADIO/reporte" +mkdir -p "$HOME/G Radio/inicio-espacio-pub" +mkdir -p "$HOME/G Radio/fin-espacio-pub" + +# Migrar archivo device antiguo a gradio.config si existe +DEVICE_FILE="$HOME_GRADIO/tmp/device" +CONFIG_FILE="$HOME_GRADIO/tmp/gradio.config" +if [ -f "$DEVICE_FILE" ] && [ ! -f "$CONFIG_FILE" ]; then + echo "==> Migrando device → gradio.config..." + MIX=$(cat "$HOME_GRADIO/mix" 2>/dev/null || echo "3.0") + paste -d"\n" "$DEVICE_FILE" /dev/null > "$CONFIG_FILE" + printf "G Radio\n%s\n" "$MIX" >> "$CONFIG_FILE" + rm -f "$DEVICE_FILE" +fi + +# Limpiar listas temporales y playlist (toma la programación de la hora actual) +> "$HOME_GRADIO/tmp/comercialeslist4" +> "$HOME_GRADIO/tmp/eventos-esperalist" +> "$HOME_GRADIO/tmp/eventoslist" +> "$HOME_GRADIO/tmp/playlist4" +echo "==> Listas temporales y playlist limpiadas." + +# ── Lanzar auxiliares guardando su PID ─────────────────────────────────────── +echo "==> Iniciando comercial-scheduler..." +"$BIN/comercial-scheduler" & +SCHED_PID=$! +echo "$SCHED_PID" > "$PIDDIR/comercial-scheduler.pid" +echo " PID: $SCHED_PID" + +echo "==> Iniciando playlist-refill..." +"$BIN/playlist-refill" & +REFILL_PID=$! +echo "$REFILL_PID" > "$PIDDIR/playlist-refill.pid" +echo " PID: $REFILL_PID" + +# ── Watchdog: verifica cada 30s que los auxiliares siguen vivos ────────────── +watchdog() { + while true; do + sleep 30 + # Si la UI ya no existe, salir + kill -0 "$RADIO_PID" 2>/dev/null || break + + if ! kill -0 "$SCHED_PID" 2>/dev/null; then + echo "==> WATCHDOG: comercial-scheduler caído, reiniciando..." + "$BIN/comercial-scheduler" & + SCHED_PID=$! + echo "$SCHED_PID" > "$PIDDIR/comercial-scheduler.pid" + fi + + if ! kill -0 "$REFILL_PID" 2>/dev/null; then + echo "==> WATCHDOG: playlist-refill caído, reiniciando..." + "$BIN/playlist-refill" & + REFILL_PID=$! + echo "$REFILL_PID" > "$PIDDIR/playlist-refill.pid" + fi + done +} + +# ── Inhibir suspensión / DPMS en ARM (previene interferencia con el main loop GTK4) ── +# En ARM+Wayland el compositor puede congelar el GLib main loop al apagar la pantalla, +# causando que los detectores de silencio disparen en falso al reanudar. +# Se intenta con systemd-inhibit (funciona en X11 y Wayland); si no está disponible +# se usa xset para deshabilitar DPMS en X11. +if command -v systemd-inhibit &>/dev/null; then + echo "==> Inhibiendo idle con systemd-inhibit..." + # Lanzar el player dentro de systemd-inhibit para bloquear el idle del sistema + RUST_LOG=info systemd-inhibit \ + --what=idle \ + --who="G Radio Player" \ + --why="Reproducción de audio en curso" \ + --mode=block \ + "$BIN/radio-player" & + RADIO_PID=$! +else + # Fallback X11: deshabilitar screensaver y DPMS + xset s off 2>/dev/null || true + xset -dpms 2>/dev/null || true + xset s noblank 2>/dev/null || true + + # ── Iniciar UI ────────────────────────────────────────────────────────────── + echo "==> Iniciando radio-player (UI)..." + RUST_LOG=info "$BIN/radio-player" & + RADIO_PID=$! +fi + +# Lanzar watchdog en segundo plano +watchdog & +WATCHDOG_PID=$! + +# Esperar a que la UI termine +wait "$RADIO_PID" + +# ── Limpieza al salir ───────────────────────────────────────────────────────── +echo "==> Cerrando procesos auxiliares..." +kill "$WATCHDOG_PID" 2>/dev/null +kill_aux "comercial-scheduler" +kill_aux "playlist-refill" +rm -f "$PIDDIR/comercial-scheduler.pid" "$PIDDIR/playlist-refill.pid" +echo "==> Terminado." diff --git a/src/audio_probe.rs b/src/audio_probe.rs new file mode 100644 index 0000000..1ea63fd --- /dev/null +++ b/src/audio_probe.rs @@ -0,0 +1,95 @@ +// audio_probe.rs — Validación de archivos de audio con symphonia. +// +// Usado por playlist-refill para descartar archivos cuyo contenedor está +// intacto (ffprobe les saca duración) pero cuyos paquetes de audio están +// rotos y cuelgan al pipeline GStreamer en runtime. +// +// La función `is_playable` abre el archivo, identifica el formato, decodifica +// hasta 5 paquetes del track principal y devuelve true si todo eso ocurre +// sin error. No lee el archivo entero — basta para detectar corrupción de +// header, codec no soportado o paquetes iniciales rotos. + +use std::fs::File; +use std::path::Path; + +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymError; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +const PACKETS_REQUIRED: usize = 5; +const MAX_PACKETS_SCANNED: usize = 200; + +/// Devuelve true si el archivo se puede decodificar sin errores. +pub fn is_playable(path: &Path) -> bool { + let file = match File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = match symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) { + Ok(p) => p, + Err(_) => return false, + }; + let mut format = probed.format; + + let track = match format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + { + Some(t) => t, + None => return false, + }; + let track_id = track.id; + + let mut decoder = match symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + { + Ok(d) => d, + Err(_) => return false, + }; + + let mut decoded = 0usize; + for _ in 0..MAX_PACKETS_SCANNED { + let packet = match format.next_packet() { + Ok(p) => p, + Err(SymError::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + // EOF temprano: válido sólo si ya decodificamos algo + return decoded > 0; + } + Err(_) => return false, + }; + if packet.track_id() != track_id { + continue; + } + match decoder.decode(&packet) { + Ok(_) => { + decoded += 1; + if decoded >= PACKETS_REQUIRED { + return true; + } + } + // DecodeError es recuperable per-packet, pero si pasa al inicio + // tratamos al archivo como corrupto. + Err(SymError::DecodeError(_)) => return false, + Err(_) => return false, + } + } + decoded > 0 +} diff --git a/src/bin/gr-botonera.rs b/src/bin/gr-botonera.rs new file mode 100644 index 0000000..0bf9835 --- /dev/null +++ b/src/bin/gr-botonera.rs @@ -0,0 +1,22 @@ +// bin/gr-botonera.rs — Botonera de efectos G-Radio + +use gtk4 as gtk; +use glib; +use gtk::prelude::*; +use gtk::Application; +use gstreamer as gst; + +const APP_ID: &str = "com.gradio.botonera"; + +fn main() -> glib::ExitCode { + grpautaje::i18n::init(grpautaje::locale_from_config().as_deref()); + gst::init().expect("GStreamer init falló"); + let app = Application::builder() + .application_id(APP_ID) + .build(); + app.connect_activate(|app| { + if let Some(w) = app.active_window() { w.present(); return; } + grpautaje::ui::botonera::ventana_botonera::construir_ventana_botonera(app); + }); + app.run() +} diff --git a/src/bin/gr-parrilla.rs b/src/bin/gr-parrilla.rs new file mode 100644 index 0000000..0167e55 --- /dev/null +++ b/src/bin/gr-parrilla.rs @@ -0,0 +1,20 @@ +// bin/gr-parrilla.rs — Programador de parrilla musical G-Radio + +use gtk4 as gtk; +use glib; +use gtk::prelude::*; +use gtk::Application; + +const APP_ID: &str = "com.gradio.parrilla"; + +fn main() -> glib::ExitCode { + grpautaje::i18n::init(grpautaje::locale_from_config().as_deref()); + let app = Application::builder() + .application_id(APP_ID) + .build(); + app.connect_activate(|app| { + if let Some(w) = app.active_window() { w.present(); return; } + grpautaje::ui::parrilla::ventana_parrilla::construir_ventana_parrilla(app); + }); + app.run() +} diff --git a/src/bin/gr-pautaje.rs b/src/bin/gr-pautaje.rs new file mode 100644 index 0000000..fa18de4 --- /dev/null +++ b/src/bin/gr-pautaje.rs @@ -0,0 +1,20 @@ +// bin/gr-pautaje.rs — Calendarizador de comerciales G-Radio + +use gtk4 as gtk; +use glib; +use gtk::prelude::*; +use gtk::Application; + +const APP_ID: &str = "com.gradio.pautaje"; + +fn main() -> glib::ExitCode { + grpautaje::i18n::init(grpautaje::locale_from_config().as_deref()); + let app = Application::builder() + .application_id(APP_ID) + .build(); + app.connect_activate(|app| { + if let Some(w) = app.active_window() { w.present(); return; } + grpautaje::ui::ventana_principal::construir_ui(app); + }); + app.run() +} diff --git a/src/bin/gr-visor.rs b/src/bin/gr-visor.rs new file mode 100644 index 0000000..eb6a8b0 --- /dev/null +++ b/src/bin/gr-visor.rs @@ -0,0 +1,20 @@ +// bin/gr-visor.rs — Visor de pautaje G-Radio + +use gtk4 as gtk; +use glib; +use gtk::prelude::*; +use gtk::Application; + +const APP_ID: &str = "com.gradio.visor"; + +fn main() -> glib::ExitCode { + grpautaje::i18n::init(grpautaje::locale_from_config().as_deref()); + let app = Application::builder() + .application_id(APP_ID) + .build(); + app.connect_activate(|app| { + if let Some(w) = app.active_window() { w.present(); return; } + grpautaje::ui::visor::ventana_visor::construir_ventana_visor(app); + }); + app.run() +} diff --git a/src/comercial_scheduler.rs b/src/comercial_scheduler.rs new file mode 100644 index 0000000..36197d0 --- /dev/null +++ b/src/comercial_scheduler.rs @@ -0,0 +1,367 @@ +use chrono::{Datelike, Local, Timelike}; +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, SystemTime}; + +fn played_breaks_path() -> PathBuf { + dirs::home_dir().unwrap().join(".gradio/data/tmp/played_breaks") +} + +fn is_break_played(hour: u32, minute: u32) -> bool { + let today = chrono::Local::now().format("%Y%m%d").to_string(); + let key = format!("{} {:02}:{:02}", today, hour, minute); + let path = played_breaks_path(); + if let Ok(content) = fs::read_to_string(&path) { + return content.lines().any(|l| l.trim() == key); + } + false +} + +// Cache de validación (path + mtime → playable). Evita re-decodificar el +// mismo comercial/evento cada minuto. +static VALIDATION_CACHE: OnceLock>> = OnceLock::new(); +fn validation_cache() -> &'static Mutex> { + VALIDATION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn is_url(s: &str) -> bool { + s.starts_with("http://") || s.starts_with("https://") +} + +fn is_special_token(s: &str) -> bool { + s.eq_ignore_ascii_case("hora") +} + +fn is_playable_cached(path: &Path) -> bool { + let path_str = path.to_string_lossy(); + if is_url(&path_str) || is_special_token(&path_str) { + return true; + } + let mtime = match fs::metadata(path).and_then(|m| m.modified()) { + Ok(t) => t, + Err(_) => return false, + }; + let key = (path.to_path_buf(), mtime); + { + let cache = validation_cache().lock().unwrap(); + if let Some(&v) = cache.get(&key) { return v; } + } + let ok = grpautaje::audio_probe::is_playable(path); + if !ok { + eprintln!("[sched] audio inválido, descartado: {}", path.display()); + } + validation_cache().lock().unwrap().insert(key, ok); + ok +} + +fn main() { + loop { + wait_until_second_58(); + + let now = Local::now(); + let target = now + chrono::Duration::seconds(2); + let h = target.hour(); + let m = target.minute(); + let dow = target.weekday().number_from_monday(); + eprintln!("[sched] === {:02}:{:02}:{:02} — procesando pauta {}:{:02} (dow={}) ===", + now.hour(), now.minute(), now.second(), h, m, dow); + + schedule_comerciales(h, m); + schedule_eventos_espera(h, m); + schedule_eventos(h, m); + + thread::sleep(Duration::from_millis(1500)); + } +} + +/// Si la ruta es una carpeta (termina en /*, / o es un directorio existente), +/// elige un audio aleatorio de ella. Si es un archivo concreto lo devuelve tal cual. +/// Retorna None si la carpeta está vacía o no existe. +fn resolve_path(raw: &str) -> Option { + let raw = raw.trim().trim_end_matches('\r'); + + if is_url(raw) { + return Some(raw.to_string()); + } + + // Normalizar: quitar /* y / al final + let normalized = raw + .trim_end_matches('/') + .trim_end_matches('*') + .trim_end_matches('/'); + + let p = Path::new(normalized); + + if p.is_dir() { + // Elegir audio aleatorio de la carpeta + let mut files: Vec = std::fs::read_dir(p) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|f| f.is_file() && is_audio(f)) + .collect(); + + if files.is_empty() { + eprintln!("[sched] SKIP carpeta vacía: {}", normalized); + return None; + } + + // Mezcla simple con índice pseudoaleatorio basado en tiempo + let idx = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as usize) + % files.len(); + + files.sort(); // orden determinista antes de elegir + // Rotar por el índice para distribuir mejor + let chosen = &files[idx % files.len()]; + let path_str = chosen.to_string_lossy().to_string(); + eprintln!("[sched] carpeta→random: {} → {}", normalized, + chosen.file_name().unwrap_or_default().to_string_lossy()); + Some(path_str) + } else if p.exists() && is_audio(p) { + Some(normalized.to_string()) + } else if p.exists() { + // Existe pero no es audio ni carpeta (ej. "Hora", streams) + Some(normalized.to_string()) + } else { + // Podría ser "Hora" u otro token especial sin ruta real + Some(raw.to_string()) + } +} + +fn is_audio(p: &Path) -> bool { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| matches!(e.to_ascii_lowercase().as_str(), + "mp3" | "wav" | "ogg" | "flac" | "m4a")) + .unwrap_or(false) +} + +fn wait_until_second_58() { + loop { + let now = Local::now(); + let sec = now.second(); + + if sec == 58 { + return; + } + + let ms_current = sec as i64 * 1000 + now.timestamp_subsec_millis() as i64; + let ms_target = 58 * 1000i64; + + let ms_to_wait = if ms_current < ms_target { + ms_target - ms_current + } else { + 60 * 1000 - ms_current + ms_target + }; + + thread::sleep(Duration::from_millis(ms_to_wait.max(10) as u64)); + } +} + +fn schedule_comerciales(hour: u32, minute: u32) { + // ── Verificar si esta tanda ya fue reproducida manualmente ────────────── + if is_break_played(hour, minute) { + eprintln!("[sched] comerciales {hour}:{minute:02} — YA REPRODUCIDO MANUALMENTE, salteando"); + return; + } + + let target_time = Local::now() + chrono::Duration::seconds(2); + let dow = target_time.weekday().number_from_monday(); + + let comercial_path = comercial_file(hour, minute); + if !comercial_path.exists() { return; } + + let out_path = dirs::home_dir().unwrap().join(".gradio/data/tmp/comercialeslist4"); + let mut out = OpenOptions::new().create(true).append(true).open(&out_path).unwrap(); + let file = match File::open(&comercial_path) { Ok(f) => f, Err(_) => return }; + + let mut loaded = 0u32; + let mut skipped_day = 0u32; + let mut skipped_date = 0u32; + + for line in BufReader::new(file).lines() { + let line = match line { Ok(l) => l, Err(_) => continue }; + let line = line.trim().to_string(); + if line.is_empty() { continue; } + + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() != 4 { + eprintln!("[sched] comerciales {hour}:{minute:02} — línea inválida: {line}"); + continue; + } + + let path = parts[0].trim(); + let days_mask = parts[1].trim(); + let start_date = parts[2].trim(); + let end_date = parts[3].trim(); + + if !valid_day(dow, days_mask) { + eprintln!("[sched] SKIP día — mask={days_mask} hoy=dow{dow} — {path}"); + skipped_day += 1; + continue; + } + if !in_date_range(target_time, start_date, end_date) { + eprintln!("[sched] SKIP fecha — {start_date}~{end_date} hoy={} — {path}", + target_time.format("%Y%m%d")); + skipped_date += 1; + continue; + } + + // Resolver carpeta → audio concreto + match resolve_path(path) { + Some(resolved) => { + if !is_playable_cached(Path::new(&resolved)) { continue; } + writeln!(out, "{}", resolved).unwrap(); + loaded += 1; + } + None => { + eprintln!("[sched] SKIP sin audio — {path}"); + } + } + } + eprintln!("[sched] comerciales {hour}:{minute:02} → cargados={loaded} skip_dia={skipped_day} skip_fecha={skipped_date}"); +} + +fn comercial_file(hour: u32, minute: u32) -> PathBuf { + dirs::home_dir().unwrap() + .join(".gradio/data/comerciales") + .join(format!("{}", hour)) + .join(format!("{}.com", minute)) +} + +fn schedule_eventos_espera(hour: u32, minute: u32) { + let target_time = Local::now() + chrono::Duration::seconds(2); + let dow = target_time.weekday().number_from_monday(); + + let evento_path = evento_espera_file(hour, minute); + if !evento_path.exists() { return; } + + let out_path = dirs::home_dir().unwrap().join(".gradio/data/tmp/eventos-esperalist"); + let mut out = OpenOptions::new().create(true).append(true).open(out_path).unwrap(); + + let mut loaded = 0u32; + let mut skipped_day = 0u32; + let mut skipped_date = 0u32; + + for line in BufReader::new(File::open(&evento_path).unwrap()).lines() { + let line = match line { Ok(l) => l, Err(_) => continue }; + let line = line.trim().to_string(); + if line.is_empty() { continue; } + + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() != 4 { continue; } + + let path = parts[0].trim(); + let days_mask = parts[1].trim(); + let start_date = parts[2].trim(); + let end_date = parts[3].trim(); + + if !valid_day(dow, days_mask) { + eprintln!("[sched] SKIP día ev-espera — mask={days_mask} hoy=dow{dow} — {path}"); + skipped_day += 1; + continue; + } + if !in_date_range(target_time, start_date, end_date) { + eprintln!("[sched] SKIP fecha ev-espera — {start_date}~{end_date} — {path}"); + skipped_date += 1; + continue; + } + + match resolve_path(path) { + Some(resolved) => { + if !is_playable_cached(Path::new(&resolved)) { continue; } + writeln!(out, "{}", resolved).unwrap(); + loaded += 1; + } + None => { eprintln!("[sched] SKIP sin audio ev-espera — {path}"); } + } + } + eprintln!("[sched] ev-espera {hour}:{minute:02} → cargados={loaded} skip_dia={skipped_day} skip_fecha={skipped_date}"); +} + +fn evento_espera_file(hour: u32, minute: u32) -> PathBuf { + dirs::home_dir().unwrap() + .join(".gradio/data/eventos-espera") + .join(format!("{}", hour)) + .join(format!("{}.com", minute)) +} + +fn schedule_eventos(hour: u32, minute: u32) { + let target_time = Local::now() + chrono::Duration::seconds(2); + let dow = target_time.weekday().number_from_monday(); + + let evento_path = evento_file(hour, minute); + if !evento_path.exists() { return; } + + let out_path = dirs::home_dir().unwrap().join(".gradio/data/tmp/eventoslist"); + let mut out = OpenOptions::new().create(true).append(true).open(out_path).unwrap(); + + let mut loaded = 0u32; + let mut skipped_day = 0u32; + let mut skipped_date = 0u32; + + for line in BufReader::new(File::open(&evento_path).unwrap()).lines() { + let line = match line { Ok(l) => l, Err(_) => continue }; + let line = line.trim().to_string(); + if line.is_empty() { continue; } + + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() != 4 { continue; } + + let path = parts[0].trim(); + let days_mask = parts[1].trim(); + let start_date = parts[2].trim(); + let end_date = parts[3].trim(); + + if !valid_day(dow, days_mask) { + eprintln!("[sched] SKIP día eventos — mask={days_mask} hoy=dow{dow} — {path}"); + skipped_day += 1; + continue; + } + if !in_date_range(target_time, start_date, end_date) { + eprintln!("[sched] SKIP fecha eventos — {start_date}~{end_date} — {path}"); + skipped_date += 1; + continue; + } + + match resolve_path(path) { + Some(resolved) => { + if !is_playable_cached(Path::new(&resolved)) { continue; } + writeln!(out, "{}", resolved).unwrap(); + loaded += 1; + } + None => { eprintln!("[sched] SKIP sin audio eventos — {path}"); } + } + } + eprintln!("[sched] eventos {hour}:{minute:02} → cargados={loaded} skip_dia={skipped_day} skip_fecha={skipped_date}"); +} + +fn evento_file(hour: u32, minute: u32) -> PathBuf { + dirs::home_dir().unwrap() + .join(".gradio/data/eventos") + .join(format!("{}", hour)) + .join(format!("{}.com", minute)) +} + +fn valid_day(today: u32, mask: &str) -> bool { + if mask == "0" || mask.is_empty() { return true; } + mask.chars().any(|c| c.to_digit(10) == Some(today)) +} + +fn in_date_range(now: chrono::DateTime, start: &str, end: &str) -> bool { + let today = now.date_naive(); + let start_ok = start == "0" || start.is_empty() || + chrono::NaiveDate::parse_from_str(start, "%Y%m%d") + .map(|d| today >= d).unwrap_or(true); + let end_ok = end == "0" || end.is_empty() || + chrono::NaiveDate::parse_from_str(end, "%Y%m%d") + .map(|d| today <= d).unwrap_or(true); + start_ok && end_ok +} diff --git a/src/duracion_audio.rs b/src/duracion_audio.rs new file mode 100644 index 0000000..c7340fd --- /dev/null +++ b/src/duracion_audio.rs @@ -0,0 +1,240 @@ +// duracion_audio.rs — Lectura de duración de archivos de audio sin dependencias externas +// +// Soporta: MP3, WAV, OGG (aproximado), FLAC (aproximado) +// Para URLs y rutas con /* retorna 0. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +/// Retorna la duración en segundos del archivo de audio. +/// Retorna 0 si no se puede determinar (URL, carpeta/*, error). +pub fn leer_duracion(ruta: &str) -> u64 { + // Descartar URLs y comodines + if ruta.starts_with("http") || ruta.ends_with("/*") || ruta == "Hora" { + return 0; + } + // Separar ruta de duración tab si viene con \t (streaming) + let ruta_limpia = ruta.split('\t').next().unwrap_or(ruta); + let path = Path::new(ruta_limpia); + let ext = path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + match ext.as_str() { + "mp3" => leer_duracion_mp3(ruta_limpia), + "wav" => leer_duracion_wav(ruta_limpia), + "ogg" => leer_duracion_ogg(ruta_limpia), + "flac" => leer_duracion_flac(ruta_limpia), + "aac" | "m4a"=> leer_duracion_m4a(ruta_limpia), + _ => 0, + } +} + +/// Formatea segundos como "H:MM:SS" o "MM:SS" +pub fn formato_duracion(segs: u64) -> String { + if segs == 0 { return "0:00".to_string(); } + let h = segs / 3600; + let m = (segs % 3600) / 60; + let s = segs % 60; + if h > 0 { + format!("{}:{:02}:{:02}", h, m, s) + } else { + format!("{}:{:02}", m, s) + } +} + +// ─── MP3 ─────────────────────────────────────────────────────────────────── +// +// Estrategia: leer el header ID3v2 para obtener el tamaño total del tag, +// luego buscar el primer frame MPEG válido y extraer bitrate + sample rate. +// Duración ≈ (tamaño_total_frames) / (bitrate_bytes_por_segundo) + +fn leer_duracion_mp3(ruta: &str) -> u64 { + let mut f = match File::open(ruta) { Ok(f) => f, Err(_) => return 0 }; + let file_size = match f.seek(SeekFrom::End(0)) { Ok(s) => s, Err(_) => return 0 }; + let _ = f.seek(SeekFrom::Start(0)); + + let mut buf4 = [0u8; 4]; + let mut offset: u64 = 0; + + // Saltar ID3v2 si existe + if f.read_exact(&mut buf4).is_ok() { + if &buf4[0..3] == b"ID3" { + // ID3v2: bytes 6-9 son tamaño syncsafe + let mut tag_size_buf = [0u8; 6]; + if f.read_exact(&mut tag_size_buf).is_ok() { + let sz = ((tag_size_buf[2] as u64) << 21) + | ((tag_size_buf[3] as u64) << 14) + | ((tag_size_buf[4] as u64) << 7) + | (tag_size_buf[5] as u64); + offset = 10 + sz; + } + } + let _ = f.seek(SeekFrom::Start(offset)); + } + + // Buscar primer frame MPEG válido (máx 64KB de búsqueda) + let mut search_buf = vec![0u8; 65536.min(file_size as usize)]; + let n = f.read(&mut search_buf).unwrap_or(0); + + for i in 0..n.saturating_sub(3) { + let b = &search_buf[i..i+4]; + // Sync word: 11 bits a 1 + if b[0] != 0xFF || (b[1] & 0xE0) != 0xE0 { continue; } + // Versión MPEG y capa + let version = (b[1] >> 3) & 0x03; + let layer = (b[1] >> 1) & 0x03; + if version == 1 || layer == 0 { continue; } + + let bitrate_idx = (b[2] >> 4) as usize; + let samplerate_idx= ((b[2] >> 2) & 0x03) as usize; + if bitrate_idx == 0 || bitrate_idx == 15 { continue; } + if samplerate_idx == 3 { continue; } + + let bitrate = mp3_bitrate(version, layer, bitrate_idx); + if bitrate == 0 { continue; } + let _samplerate = mp3_samplerate(version, samplerate_idx); + + // Duración estimada por tamaño de archivo / bitrate + let audio_bytes = file_size.saturating_sub(offset + i as u64); + return (audio_bytes * 8) / (bitrate as u64 * 1000); + } + 0 +} + +fn mp3_bitrate(version: u8, layer: u8, idx: usize) -> u32 { + // version: 3=MPEG1, 2=MPEG2, 0=MPEG2.5; layer: 3=L1, 2=L2, 1=L3 + const TABLE_V1_L3: [u32;16] = [0,32,40,48,56,64,80,96,112,128,160,192,224,256,320,0]; + const TABLE_V1_L2: [u32;16] = [0,32,48,56,64,80,96,112,128,160,192,224,256,320,384,0]; + const TABLE_V1_L1: [u32;16] = [0,32,64,96,128,160,192,224,256,288,320,352,384,416,448,0]; + const TABLE_V2_L3: [u32;16] = [0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0]; + const TABLE_V2_L12:[u32;16] = [0,32,48,56,64,80,96,112,128,144,160,176,192,224,256,0]; + match (version, layer) { + (3, 1) => TABLE_V1_L3[idx], + (3, 2) => TABLE_V1_L2[idx], + (3, 3) => TABLE_V1_L1[idx], + (_, 1) => TABLE_V2_L3[idx], + _ => TABLE_V2_L12[idx], + } +} + +fn mp3_samplerate(version: u8, idx: usize) -> u32 { + const SR_V1: [u32;3] = [44100,48000,32000]; + const SR_V2: [u32;3] = [22050,24000,16000]; + const SR_V25:[u32;3] = [11025,12000, 8000]; + let table = match version { 3 => &SR_V1, 2 => &SR_V2, _ => &SR_V25 }; + if idx < 3 { table[idx] } else { 44100 } +} + +// ─── WAV ─────────────────────────────────────────────────────────────────── + +fn leer_duracion_wav(ruta: &str) -> u64 { + let mut f = match File::open(ruta) { Ok(f) => f, Err(_) => return 0 }; + let mut header = [0u8; 44]; + if f.read_exact(&mut header).is_err() { return 0; } + if &header[0..4] != b"RIFF" || &header[8..12] != b"WAVE" { return 0; } + // fmt chunk offset 12 + let channels = u16::from_le_bytes([header[22], header[23]]) as u64; + let sample_rate = u32::from_le_bytes([header[24],header[25],header[26],header[27]]) as u64; + let bits = u16::from_le_bytes([header[34], header[35]]) as u64; + let data_size = u32::from_le_bytes([header[40],header[41],header[42],header[43]]) as u64; + if channels == 0 || sample_rate == 0 || bits == 0 { return 0; } + data_size / (sample_rate * channels * (bits / 8)) +} + +// ─── OGG (aproximado) ───────────────────────────────────────────────────── + +fn leer_duracion_ogg(ruta: &str) -> u64 { + // Leer los últimos 65536 bytes y buscar la última página OGG + // que contiene el granule position (posición de muestra) + let mut f = match File::open(ruta) { Ok(f) => f, Err(_) => return 0 }; + let file_size = match f.seek(SeekFrom::End(0)) { Ok(s) => s, Err(_) => return 0 }; + let read_from = file_size.saturating_sub(65536); + let _ = f.seek(SeekFrom::Start(read_from)); + let mut buf = vec![0u8; (file_size - read_from) as usize]; + let n = f.read(&mut buf).unwrap_or(0); + + // Buscar la última captura OGgS + granule_position + let mut last_granule: u64 = 0; + for i in 0..n.saturating_sub(27) { + if &buf[i..i+4] == b"OggS" { + let gp = u64::from_le_bytes([ + buf[i+6],buf[i+7],buf[i+8],buf[i+9], + buf[i+10],buf[i+11],buf[i+12],buf[i+13], + ]); + if gp != u64::MAX && gp > last_granule { + last_granule = gp; + } + } + } + // Sample rate típico de Vorbis: 44100. Para mayor precisión habría que + // parsear el identification header, pero 44100 es correcto el 95% del tiempo. + if last_granule > 0 { last_granule / 44100 } else { 0 } +} + +// ─── FLAC (aproximado) ──────────────────────────────────────────────────── + +fn leer_duracion_flac(ruta: &str) -> u64 { + let mut f = match File::open(ruta) { Ok(f) => f, Err(_) => return 0 }; + let mut magic = [0u8; 4]; + if f.read_exact(&mut magic).is_err() { return 0; } + if &magic != b"fLaC" { return 0; } + // Leer STREAMINFO (primer metadata block) + let mut block_header = [0u8; 4]; + if f.read_exact(&mut block_header).is_err() { return 0; } + let block_len = u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize; + if block_len < 18 { return 0; } + let mut info = vec![0u8; block_len]; + if f.read_exact(&mut info).is_err() { return 0; } + // sample_rate: bits 80-99 del STREAMINFO + // total_samples: bits 108-143 + let sample_rate = ((info[10] as u32) << 12) + | ((info[11] as u32) << 4) + | ((info[12] as u32) >> 4); + // total_samples en los últimos 36 bits del bloque de 18 bytes + let total = ((info[13] as u64 & 0x0F) << 32) + | ((info[14] as u64) << 24) + | ((info[15] as u64) << 16) + | ((info[16] as u64) << 8) + | (info[17] as u64); + if sample_rate == 0 { return 0; } + total / sample_rate as u64 +} + +// ─── M4A/AAC (aproximado por tamaño) ────────────────────────────────────── + +fn leer_duracion_m4a(ruta: &str) -> u64 { + // Búsqueda de la caja 'mvhd' que contiene la duración + let mut f = match File::open(ruta) { Ok(f) => f, Err(_) => return 0 }; + let file_size = match f.seek(SeekFrom::End(0)) { Ok(s) => s, Err(_) => return 0 }; + let _ = f.seek(SeekFrom::Start(0)); + let mut buf = vec![0u8; file_size.min(1_000_000) as usize]; + let n = f.read(&mut buf).unwrap_or(0); + + for i in 0..n.saturating_sub(24) { + if &buf[i+4..i+8] == b"mvhd" { + // version 0: time_scale en offset 12, duration en offset 16 + // version 1: time_scale en offset 20, duration en offset 24 (u64) + let version = buf[i + 8]; + if version == 0 && i + 24 <= n { + let time_scale = u32::from_be_bytes([buf[i+12],buf[i+13],buf[i+14],buf[i+15]]); + let duration = u32::from_be_bytes([buf[i+16],buf[i+17],buf[i+18],buf[i+19]]); + if time_scale > 0 { + return duration as u64 / time_scale as u64; + } + } else if version == 1 && i + 32 <= n { + let time_scale = u32::from_be_bytes([buf[i+20],buf[i+21],buf[i+22],buf[i+23]]); + let duration = u64::from_be_bytes([ + buf[i+24],buf[i+25],buf[i+26],buf[i+27], + buf[i+28],buf[i+29],buf[i+30],buf[i+31], + ]); + if time_scale > 0 { + return duration / time_scale as u64; + } + } + } + } + 0 +} diff --git a/src/gr_buscador.rs b/src/gr_buscador.rs new file mode 100644 index 0000000..ededc9d --- /dev/null +++ b/src/gr_buscador.rs @@ -0,0 +1,1202 @@ +// gr_buscador.rs — Buscador de audio para G Radio Player +// +// Busca recursivamente en las carpetas de música archivos que contengan +// el texto ingresado. Los resultados se muestran en una tabla con: +// Tema Encontrado | Tiempo | Ruta +// +// Doble clic → agrega al final de playlist4 +// Arrastre → el operador ubica el archivo en la lista de main.rs + +use gtk4 as gtk; +use gtk::prelude::*; +use gtk::{ + Application, ApplicationWindow, Box as GtkBox, Button, Entry, + Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, + GestureClick, CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, +}; +use gdk4::ContentProvider; + +const ICONO_BUSCADOR: &[u8] = include_bytes!("../assets/busqueda.png"); +const ICONO_UP: &[u8] = include_bytes!("../assets/up.png"); +const ICONO_DOWN: &[u8] = include_bytes!("../assets/down.png"); +use glib; +use gstreamer; +use gstreamer::prelude::{ + ElementExt, ElementExtManual, GstBinExtManual, PadExt, +}; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::thread; + +use grpautaje::i18n::tr; +use grpautaje::skin; + +const APP_ID: &str = "com.gradio.buscador"; + +fn is_audio(p: &Path) -> bool { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| matches!(e.to_ascii_lowercase().as_str(), + "mp3" | "wav" | "ogg" | "flac" | "m4a" | "mp4" | "mkv" | "avi")) + .unwrap_or(false) +} + +/// Busca archivos de audio que contengan `query` (case-insensitive). +/// +/// Estrategia (en orden de preferencia): +/// 1. `locate -i ` — usa el índice del sistema, instantáneo. +/// 2. Si locate no está disponible, búsqueda recursiva desde las carpetas +/// de la parrilla y directorios habituales de música. +fn search_files(_roots: &[PathBuf], query: &str) -> Vec { + // Intentar locate primero + if let Ok(results) = search_with_locate(query) { + if !results.is_empty() { + return results; + } + } + // Fallback: búsqueda recursiva + let roots = search_roots_fallback(); + let query_lower = query.to_lowercase(); + let mut results = Vec::new(); + for root in &roots { + search_recursive(root, &query_lower, &mut results); + } + results.sort(); + results +} + +/// Usa `locate -i ` y filtra solo archivos de audio existentes. +/// Prioriza la base de datos propia (~/.gradio/data/locatedb) si existe, +/// luego la del sistema como fallback. +fn search_with_locate(query: &str) -> Result, ()> { + let check = Command::new("locate").arg("--version").output(); + if check.is_err() { return Err(()); } + + // Base de datos propia (generada con "⟳ Índice", no requiere sudo) + let db_propia = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/")) + .join(".gradio/data/locatedb"); + + let mut cmd = Command::new("locate"); + cmd.arg("-i").arg("--"); + if db_propia.exists() { + cmd.arg("-d").arg(&db_propia); + } + cmd.arg(query); + + let out = cmd.output().map_err(|_| ())?; + + let stdout = String::from_utf8_lossy(&out.stdout); + let results: Vec = stdout + .lines() + .map(|l| PathBuf::from(l.trim())) + .filter(|p| p.exists() && is_audio(p)) + .collect(); + + Ok(results) +} + +/// Carpetas de búsqueda para el fallback recursivo. +/// Lee las rutas de los archivos .mus de la parrilla; si no hay, usa defaults. +fn search_roots_fallback() -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let mut roots: Vec = Vec::new(); + + let parrilla_dir = home.join(".gradio/data/parrilla"); + if parrilla_dir.exists() { + collect_mus_dirs(&parrilla_dir, &mut roots); + } + + if roots.is_empty() { + for candidate in &["Musica", "Music", "G Radio", "001 radio"] { + let p = home.join(candidate); + if p.exists() { roots.push(p); } + } + } + + roots.sort(); + roots.dedup(); + roots +} + +fn collect_mus_dirs(dir: &Path, roots: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_mus_dirs(&path, roots); + } else if path.extension().and_then(|e| e.to_str()) == Some("mus") { + if let Ok(content) = fs::read_to_string(&path) { + for line in content.lines() { + let raw = line.trim() + .trim_end_matches('/') + .trim_end_matches('*') + .trim_end_matches('/'); + let p = PathBuf::from(raw); + if p.is_dir() { + roots.push(p); + } else if let Some(parent) = p.parent() { + if parent.exists() { + roots.push(parent.to_path_buf()); + } + } + } + } + } + } +} + +fn search_recursive(dir: &Path, query: &str, results: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + search_recursive(&path, query, results); + } else if is_audio(&path) { + let name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_lowercase(); + if name.contains(query) { + results.push(path); + } + } + } +} + +/// Obtiene la duración de un archivo con ffprobe +fn get_duration(path: &Path) -> String { + let out = Command::new("ffprobe") + .args(["-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + path.to_str().unwrap_or("")]) + .output(); + match out { + Ok(o) => { + let secs = String::from_utf8_lossy(&o.stdout) + .trim() + .parse::() + .unwrap_or(0.0); + let total = secs as u64; + format!("{:02}:{:02}:{:02}", total / 3600, (total % 3600) / 60, total % 60) + } + Err(_) => "00:00:00".to_string(), + } +} + +/// Escribe path + duración al archivo play_now para reproducción inmediata con crossfade. +fn write_play_now(path: &Path, duration: &str) { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let play_now_path = home.join(".gradio/data/tmp/play_now"); + let content = format!("{}\t{}", path.display(), duration); + let _ = fs::write(&play_now_path, &content); + eprintln!("[buscador] play_now → {}", content); +} + +/// Agrega una ruta al final de playlist4 en formato TAB +fn append_to_playlist(path: &Path) { + if !grpautaje::audio_probe::is_playable(path) { + eprintln!("[buscador] audio inválido, no agregado: {}", path.display()); + return; + } + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let playlist_path = home.join(".gradio/data/tmp/playlist4"); + + let dur = get_duration(path); + let title = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Audio") + .to_string(); + + // Formato: /ruta/archivo.mp3\tMM:SS.000 + // Convertir HH:MM:SS a MM:SS.000 + let parts: Vec<&str> = dur.splitn(3, ':').collect(); + let mm_ss = if parts.len() == 3 { + let h: u64 = parts[0].parse().unwrap_or(0); + let m: u64 = parts[1].parse().unwrap_or(0); + let s: u64 = parts[2].parse().unwrap_or(0); + format!("{:02}:{:02}.000", h * 60 + m, s) + } else { + "00:00.000".to_string() + }; + + let line = format!("{}\t{}\n", path.display(), mm_ss); + if let Ok(mut f) = fs::OpenOptions::new() + .create(true).append(true) + .open(&playlist_path) + { + use std::io::Write; + let _ = f.write_all(line.as_bytes()); + } + eprintln!("[buscador] Agregado a playlist: {} ({})", title, mm_ss); +} + +/// Inserta una ruta como PRIMERA línea de playlist4 en formato TAB. +fn prepend_to_playlist(path: &Path) { + if !grpautaje::audio_probe::is_playable(path) { + eprintln!("[buscador] audio inválido, no insertado: {}", path.display()); + return; + } + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let playlist_path = home.join(".gradio/data/tmp/playlist4"); + + let dur = get_duration(path); + let title = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Audio") + .to_string(); + + // HH:MM:SS → MM:SS.000 + let parts: Vec<&str> = dur.splitn(3, ':').collect(); + let mm_ss = if parts.len() == 3 { + let h: u64 = parts[0].parse().unwrap_or(0); + let m: u64 = parts[1].parse().unwrap_or(0); + let s: u64 = parts[2].parse().unwrap_or(0); + format!("{:02}:{:02}.000", h * 60 + m, s) + } else { + "00:00.000".to_string() + }; + + let new_line = format!("{}\t{}\n", path.display(), mm_ss); + let existing = fs::read_to_string(&playlist_path).unwrap_or_default(); + let _ = fs::write(&playlist_path, format!("{}{}", new_line, existing)); + eprintln!("[buscador] Insertado al inicio: {} ({})", title, mm_ss); +} + +/// Helper local: botón con ícono PNG escalado. +fn icon_button(png_bytes: &[u8], tooltip: &str, size: i32) -> Button { + let btn = Button::new(); + btn.set_tooltip_text(Some(tooltip)); + btn.set_size_request(size, size); + btn.set_hexpand(false); + btn.set_vexpand(false); + + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + loader.write(png_bytes).unwrap_or(()); + loader.close().unwrap_or(()); + if let Some(pixbuf) = loader.pixbuf() { + let icon_size = (size - 10).max(16); + if let Some(scaled) = pixbuf.scale_simple( + icon_size, icon_size, + gdk4::gdk_pixbuf::InterpType::Bilinear, + ) { + let texture = gdk4::Texture::for_pixbuf(&scaled); + let image = gtk4::Image::from_paintable(Some(&texture)); + btn.set_child(Some(&image)); + } + } + btn +} + +const APP_CSS: &str = r#" +/* ── Forzar tema oscuro en todos los widgets ── */ +window, .background { + background-color: #1e1e1e; + color: #e0e0e0; +} +box, scrolledwindow, viewport { + background-color: #1e1e1e; + color: #e0e0e0; +} +listbox { + background-color: #1e1e1e; + color: #e0e0e0; +} +listbox row { + background-color: #1e1e1e; + color: #e0e0e0; +} +label { color: #e0e0e0; } +separator { + background-color: #444444; + min-width: 1px; + min-height: 1px; +} + +.toolbar { + background-color: #2a2a2a; + padding: 4px 8px; + border-bottom: 1px solid #444; +} + +.search-entry { + background: #333333; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + padding: 4px 8px; + font-size: 13px; + min-width: 300px; +} + +.search-entry:focus { + border-color: #4a90d9; +} + +.header-row { + background-color: #2a3a4a; + padding: 4px 8px; + border-bottom: 1px solid #555; +} + +.header-label { + color: #aaccee; + font-weight: bold; + font-size: 14px; +} + +.result-row { + padding: 3px 8px; + border-bottom: 1px solid #2a2a2a; + background-color: #1e1e1e; +} + +.result-row:nth-child(even) { + background-color: #252525; +} + +.result-row:hover { + background-color: #2a4a6a; +} + +.result-row:selected { + background-color: #1a6496; +} + +.col-title { + color: #e0e0e0; + font-size: 15px; + min-width: 280px; +} + +.col-time { + color: #88cc88; + font-size: 15px; + min-width: 80px; +} + +.col-path { + color: #888888; + font-size: 13px; +} + +/* Botón play-now — igual que queue-idx-btn del playlist */ +button.btn-play-now, +button.btn-play-now * { + all: unset; +} +button.btn-play-now { + background: #1a6496; + color: #ffffff; + font-size: 12px; + font-weight: bold; + min-width: 28px; + min-height: 20px; + padding: 2px 6px; + border-radius: 3px; + margin-right: 4px; +} +button.btn-play-now:hover { + background: #2980b9; +} +button.btn-play-now:active { + background: #145074; +} + +/* Botones inline up/down: insertar al inicio / agregar al final */ +button.btn-pos, +button.btn-pos * { all: unset; } +button.btn-pos { + background: #2a3a4a; + border: 1px solid #3a5a7a; + border-radius: 3px; + min-width: 28px; + min-height: 24px; + margin-left: 2px; + padding: 2px; +} +button.btn-pos:hover { background: #3a6a9a; } +button.btn-pos:active { background: #1a4a7a; } + + +.status-bar { + background-color: #2a2a2a; + padding: 3px 8px; + border-top: 1px solid #444; + color: #888888; + font-size: 11px; +} + +.btn-toolbar { + background: #3a3a3a; + color: #e0e0e0; + border: 1px solid #555; + border-radius: 4px; + padding: 4px 10px; + font-size: 12px; + min-width: 32px; + min-height: 32px; +} + +.btn-toolbar:hover { + background: #4a90d9; + color: #ffffff; +} +"#; + +fn main() -> glib::ExitCode { + // i18n: leer locale del config compartido si está, si no autodetectar + let locale_cfg = read_locale_from_config(); + grpautaje::i18n::init(locale_cfg.as_deref()); + + let app = Application::builder() + .application_id(APP_ID) + .build(); + + app.connect_activate(|app| { + if let Some(w) = app.active_window() { w.present(); return; } + build_ui(app); + }); + app.run() +} + +/// Lee la línea 17 (locale) del archivo gradio_config si existe. +fn read_locale_from_config() -> Option { + let home = dirs::home_dir()?; + let path = home.join(".gradio/data/gradio_config"); + let content = std::fs::read_to_string(path).ok()?; + let line = content.lines().nth(16)?.trim(); + if line.is_empty() { None } else { Some(line.to_string()) } +} + +fn build_ui(app: &Application) { + // Solicitar tema oscuro al sistema + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_application_prefer_dark_theme(true); + } + + // CSS con prioridad máxima para pisar el tema del sistema + let provider = CssProvider::new(); + provider.load_from_data(APP_CSS); + if let Some(display) = gdk4::Display::default() { + gtk::style_context_add_provider_for_display( + &display, &provider, + gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + skin::aplicar_css_extra(&display); + } + + let window = ApplicationWindow::builder() + .application(app) + .title(tr("buscador.win_title")) + .default_width(900) + .default_height(500) + .build(); + + // Resultados compartidos entre búsqueda en hilo y UI + let results: Rc>> = Rc::new(RefCell::new(Vec::new())); + let searching = Rc::new(RefCell::new(false)); + + // ── Layout principal ────────────────────────────────────────────────────── + let vbox = GtkBox::new(Orientation::Vertical, 0); + + // ── Barra de herramientas ───────────────────────────────────────────────── + let toolbar = GtkBox::new(Orientation::Horizontal, 6); + toolbar.add_css_class("toolbar"); + toolbar.set_margin_start(4); + toolbar.set_margin_end(4); + toolbar.set_margin_top(4); + toolbar.set_margin_bottom(4); + + // Botón lupa / buscar + let btn_search = Button::new(); + btn_search.add_css_class("btn-toolbar"); + btn_search.set_tooltip_text(Some(tr("buscador.btn_search_tip"))); + { + // Ícono lupa simple con label + let lbl = Label::new(Some("🔍")); + btn_search.set_child(Some(&lbl)); + } + + // Entry de búsqueda + let entry = Entry::new(); + entry.add_css_class("search-entry"); + entry.set_placeholder_text(Some(tr("buscador.entry_ph"))); + entry.set_hexpand(true); + + // Botón limpiar + let btn_clear = Button::with_label("✕"); + btn_clear.add_css_class("btn-toolbar"); + btn_clear.set_tooltip_text(Some(tr("buscador.btn_clear"))); + + // Botón actualizar índice (updatedb) + let btn_updatedb = Button::with_label(tr("buscador.btn_index")); + btn_updatedb.add_css_class("btn-toolbar"); + btn_updatedb.set_tooltip_text(Some(tr("buscador.btn_index_tip"))); + + toolbar.append(&btn_search); + toolbar.append(&btn_clear); + toolbar.append(&entry); + toolbar.append(&btn_updatedb); + + // ── Cabecera de columnas ────────────────────────────────────────────────── + let header = GtkBox::new(Orientation::Horizontal, 0); + header.add_css_class("header-row"); + + let h_num = Label::new(Some(" #")); + let h_title = Label::new(Some(tr("buscador.col_tema"))); + let h_time = Label::new(Some(tr("buscador.col_tiempo"))); + let h_path = Label::new(Some(tr("buscador.col_ruta"))); + + for (lbl, width) in [(&h_num, 40), (&h_title, 280), (&h_time, 90), (&h_path, -1)] { + lbl.add_css_class("header-label"); + lbl.set_halign(gtk::Align::Start); + lbl.set_margin_start(4); + if width > 0 { lbl.set_size_request(width, -1); } + else { lbl.set_hexpand(true); } + header.append(lbl); + } + + // ── Lista de resultados ─────────────────────────────────────────────────── + let listbox = ListBox::new(); + listbox.set_selection_mode(gtk::SelectionMode::Single); + + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_child(Some(&listbox)); + + // ── Barra de estado ─────────────────────────────────────────────────────── + let status_bar = GtkBox::new(Orientation::Horizontal, 0); + status_bar.add_css_class("status-bar"); + let status_lbl = Label::new(Some(tr("buscador.status_ready"))); + status_lbl.set_halign(gtk::Align::Start); + status_lbl.set_margin_start(8); + status_bar.append(&status_lbl); + + vbox.append(&toolbar); + vbox.append(&header); + vbox.append(&scroll); + vbox.append(&status_bar); + window.set_child(Some(&vbox)); + + // ── Función de búsqueda ─────────────────────────────────────────────────── + let do_search = { + let entry = entry.clone(); + let listbox = listbox.clone(); + let status_lbl = status_lbl.clone(); + let results = results.clone(); + let searching = searching.clone(); + + move || { + let query = entry.text().to_string(); + if query.trim().is_empty() { return; } + + if *searching.borrow() { return; } + *searching.borrow_mut() = true; + + // Limpiar lista + while let Some(ch) = listbox.first_child() { listbox.remove(&ch); } + status_lbl.set_text(&tr("buscador.status_buscando").replace("{q}", &query)); + + let query_clone = query.clone(); + + // Buscar en hilo separado para no bloquear UI + let (tx, rx) = std::sync::mpsc::channel::>(); + thread::spawn(move || { + let found = search_files(&[], &query_clone); + let _ = tx.send(found); + }); + + // Polling con timeout_add para recibir resultado + let listbox_c = listbox.clone(); + let status_c = status_lbl.clone(); + let results_c = results.clone(); + let searching_c = searching.clone(); + + glib::timeout_add_local(std::time::Duration::from_millis(100), move || { + match rx.try_recv() { + Ok(found) => { + *results_c.borrow_mut() = found.clone(); + *searching_c.borrow_mut() = false; + + let count = found.len(); + status_c.set_text( + &tr("buscador.status_resultados") + .replace("{n}", &count.to_string()) + .replace("{q}", &query) + ); + + for (i, path) in found.iter().enumerate() { + let row = build_result_row(i + 1, path); + listbox_c.append(&row); + } + + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(_) => { + *searching_c.borrow_mut() = false; + glib::ControlFlow::Break + } + } + }); + } + }; + + // Activar con Enter en el entry + let do_search_entry = do_search.clone(); + entry.connect_activate(move |_| do_search_entry()); + + // Activar con botón lupa + let do_search_btn = do_search.clone(); + btn_search.connect_clicked(move |_| do_search_btn()); + + // Limpiar + let entry_clear = entry.clone(); + let listbox_clear = listbox.clone(); + let status_clear = status_lbl.clone(); + btn_clear.connect_clicked(move |_| { + entry_clear.set_text(""); + while let Some(ch) = listbox_clear.first_child() { listbox_clear.remove(&ch); } + status_clear.set_text(tr("buscador.status_listo")); + }); + + // Actualizar índice (updatedb) — corre en hilo separado para no bloquear GTK + { + let status2 = status_lbl.clone(); + let btn2 = btn_updatedb.clone(); + btn_updatedb.connect_clicked(move |_| { + status2.set_text(tr("buscador.status_indexando")); + btn2.set_sensitive(false); + // Resultado compartido entre hilo OS y timer GTK + let resultado: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + let res2 = resultado.clone(); + std::thread::spawn(move || { + let db_path = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/")) + .join(".gradio/data/locatedb"); + let home = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/")); + let ok = std::process::Command::new("updatedb") + .arg("--output").arg(&db_path) + .arg("--localpaths").arg(&home) + .status() + .map(|s| s.success()) + .unwrap_or(false); + *res2.lock().unwrap() = Some(ok); + }); + let status3 = status2.clone(); + let btn3 = btn2.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(500), move || { + if let Some(ok) = *resultado.lock().unwrap() { + status3.set_text(if ok { tr("buscador.status_index_ok") } else { tr("buscador.status_index_err") }); + btn3.set_sensitive(true); + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } + }); + }); + } + + // Ícono en taskbar + { + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + let _ = loader.write(&skin::icono("busqueda.png", ICONO_BUSCADOR)); + let _ = loader.close(); + if let Some(pb) = loader.pixbuf() { + let tex = gdk4::Texture::for_pixbuf(&pb); + let win = window.clone(); + window.connect_realize(move |w| { + if let Some(surf) = w.surface() { + use gdk4::prelude::ToplevelExt; + if let Some(tl) = surf.dynamic_cast_ref::() { + tl.set_icon_list(&[tex.clone()]); + } + let display = gtk4::prelude::WidgetExt::display(w); + use gdk4::prelude::DisplayExt; + if let Some(monitor) = display.monitor_at_surface(&surf) { + use gdk4::prelude::MonitorExt; + let geo = monitor.geometry(); + let ancho = (geo.width() - 20).min(950).max(600); + let alto = (geo.height() - 90).min(680).max(400); + win.set_default_size(ancho, alto); + } + } + }); + } + } + + window.present(); + // Foco en el entry al abrir + entry.grab_focus(); +} + +/// Construye una fila de resultado con número, título, tiempo y ruta. +/// Doble clic → agrega a playlist4. +/// Arrastre → DragSource con la ruta como text/uri-list. +/// Estado compartido del pipeline CUE del buscador +static CUE_PIPELINE: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + +fn get_cue_pipeline() -> &'static Arc>> { + CUE_PIPELINE.get_or_init(|| Arc::new(Mutex::new(None))) +} + +fn abrir_ventana_cue_buscador(path: &Path, title: &str) { + // Inicializar GStreamer (seguro llamar múltiples veces) + gstreamer::init().unwrap_or(()); + + let title = title.to_string(); + let uri = format!("file://{}", path.display()); + + // Detener CUE anterior + if let Ok(mut guard) = get_cue_pipeline().lock() { + if let Some(old) = guard.take() { + let _ = old.set_state(gstreamer::State::Null); + } + } + + // Leer tarjeta CUE desde gradio.config + let cue_dev: Option = { + let home = dirs::home_dir().unwrap_or_default(); + let cfg = home.join(".gradio/data/tmp/gradio.config"); + std::fs::read_to_string(cfg).ok() + .and_then(|s| s.lines().nth(1).map(|l| l.trim().to_string())) + .filter(|s| !s.is_empty()) + }; + + // Construir pipeline CUE + let pipeline = gstreamer::Pipeline::new(); + let src = match gstreamer::ElementFactory::make("uridecodebin") + .property("uri", &uri).build() { Ok(e) => e, Err(_) => return }; + let conv = gstreamer::ElementFactory::make("audioconvert").build().unwrap(); + let resamp = gstreamer::ElementFactory::make("audioresample").build().unwrap(); + let sink = if let Some(ref dev) = cue_dev { + gstreamer::ElementFactory::make("pulsesink") + .property("device", dev.as_str()).build() + .unwrap_or_else(|_| gstreamer::ElementFactory::make("autoaudiosink").build().unwrap()) + } else { + gstreamer::ElementFactory::make("autoaudiosink").build().unwrap() + }; + let _ = pipeline.add_many([&src, &conv, &resamp, &sink]); + let _ = gstreamer::Element::link_many([&conv, &resamp, &sink]); + let conv_w = conv.downgrade(); + src.connect_pad_added(move |_, pad| { + if let Some(c) = conv_w.upgrade() { + if let Some(sink_pad) = c.static_pad("sink") { + let _ = pad.link(&sink_pad); + } + } + }); + let _ = pipeline.set_state(gstreamer::State::Playing); + + // Guardar pipeline + *get_cue_pipeline().lock().unwrap() = Some(pipeline.clone()); + + // ── Ventana de control ────────────────────────────────────────────────── + let win = gtk4::Window::new(); + win.set_title(Some(&format!("🎧 CUE: {}", title))); + win.set_default_size(360, 100); + win.set_resizable(false); + + let vbox = GtkBox::new(Orientation::Vertical, 8); + vbox.set_margin_top(12); vbox.set_margin_bottom(12); + vbox.set_margin_start(12); vbox.set_margin_end(12); + + let seek = gtk4::Scale::new( + Orientation::Horizontal, + Some(>k4::Adjustment::new(0.0, 0.0, 100.0, 1.0, 5.0, 0.0)) + ); + seek.set_hexpand(true); + seek.set_draw_value(false); + + let time_lbl = gtk4::Label::new(Some("00:00 / 00:00")); + + let time_row = GtkBox::new(Orientation::Horizontal, 8); + time_row.append(&seek); + time_row.append(&time_lbl); + + let btn_row = GtkBox::new(Orientation::Horizontal, 8); + btn_row.set_halign(gtk4::Align::Center); + let btn_pause = Button::with_label(tr("buscador.btn_pause")); + let btn_stop = Button::with_label(tr("buscador.btn_stop")); + btn_row.append(&btn_pause); + btn_row.append(&btn_stop); + + vbox.append(&time_row); + vbox.append(&btn_row); + win.set_child(Some(&vbox)); + + // Pausa/Resume + let pipe_p = pipeline.clone(); + btn_pause.connect_clicked(move |btn| { + match pipe_p.current_state() { + gstreamer::State::Playing => { + let _ = pipe_p.set_state(gstreamer::State::Paused); + btn.set_label(tr("buscador.btn_resume")); + } + _ => { + let _ = pipe_p.set_state(gstreamer::State::Playing); + btn.set_label(tr("buscador.btn_pause")); + } + } + }); + + // Stop + let pipe_s = pipeline.clone(); + let win_s = win.clone(); + btn_stop.connect_clicked(move |_| { + let _ = pipe_s.set_state(gstreamer::State::Null); + *get_cue_pipeline().lock().unwrap() = None; + win_s.close(); + }); + + // Cerrar ventana = detener + let pipe_c = pipeline.clone(); + win.connect_close_request(move |_| { + let _ = pipe_c.set_state(gstreamer::State::Null); + *get_cue_pipeline().lock().unwrap() = None; + glib::Propagation::Proceed + }); + + // Timer para seek bar y tiempo + let pipe_t = pipeline.clone(); + let seek_t = seek.clone(); + let time_t = time_lbl.clone(); + let win_t = win.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(500), move || { + if !win_t.is_visible() { return glib::ControlFlow::Break; } + if let Some(pos) = pipe_t.query_position::() { + if let Some(dur) = pipe_t.query_duration::() { + let ps = pos.seconds(); + let ds = dur.seconds(); + time_t.set_text(&format!("{:02}:{:02} / {:02}:{:02}", + ps/60, ps%60, ds/60, ds%60)); + if ds > 0 { + seek_t.set_value((ps as f64 / ds as f64) * 100.0); + } + } + } + // Detectar EOS + if let Some(bus) = pipe_t.bus() { + if let Some(msg) = bus.timed_pop_filtered(gstreamer::ClockTime::ZERO, + &[gstreamer::MessageType::Eos]) { + if let gstreamer::MessageView::Eos(_) = msg.view() { + win_t.close(); + return glib::ControlFlow::Break; + } + } + } + glib::ControlFlow::Continue + }); + + // Seek al hacer clic en la barra + let pipe_sk = pipeline.clone(); + seek.connect_change_value(move |_, _, val| { + if let Some(dur) = pipe_sk.query_duration::() { + let pos = gstreamer::ClockTime::from_seconds( + (val / 100.0 * dur.seconds() as f64) as u64 + ); + let _ = pipe_sk.seek_simple( + gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::KEY_UNIT, pos + ); + } + glib::Propagation::Proceed + }); + + win.present(); +} + +fn build_result_row(num: usize, path: &Path) -> ListBoxRow { + let row_box = GtkBox::new(Orientation::Horizontal, 0); + row_box.add_css_class("result-row"); + + let title = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Audio") + .to_string(); + + let dur = get_duration(path); + let parent_str = path.parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Botón azul numérico: click → crossfade inmediato al deck libre + let btn_play = Button::with_label(&format!("{}", num)); + btn_play.add_css_class("btn-play-now"); + btn_play.set_tooltip_text(Some(tr("buscador.btn_play_now_tip"))); + { + let path_play = path.to_path_buf(); + let dur_play = dur.clone(); + btn_play.connect_clicked(move |_| { + write_play_now(&path_play, &dur_play); + }); + } + + let lbl_title = Label::new(Some(&title)); + let lbl_time = Label::new(Some(&dur)); + let lbl_path = Label::new(Some(&parent_str)); + + lbl_title.set_size_request(280, -1); + lbl_title.set_halign(gtk::Align::Start); + lbl_title.set_ellipsize(gtk::pango::EllipsizeMode::End); + lbl_title.add_css_class("col-title"); + + lbl_time.set_size_request(90, -1); + lbl_time.set_halign(gtk::Align::Start); + lbl_time.add_css_class("col-time"); + + lbl_path.set_hexpand(true); + lbl_path.set_halign(gtk::Align::Start); + lbl_path.set_ellipsize(gtk::pango::EllipsizeMode::Start); + lbl_path.add_css_class("col-path"); + + // Botones inline: insertar al inicio / al final de playlist4 + let btn_first = icon_button(&skin::icono("up.png", ICONO_UP), tr("buscador.btn_first"), 28); + let btn_last = icon_button(&skin::icono("down.png", ICONO_DOWN), tr("buscador.btn_last"), 28); + btn_first.add_css_class("btn-pos"); + btn_last.add_css_class("btn-pos"); + { + let path_first = path.to_path_buf(); + btn_first.connect_clicked(move |_| prepend_to_playlist(&path_first)); + } + { + let path_last = path.to_path_buf(); + btn_last.connect_clicked(move |_| append_to_playlist(&path_last)); + } + + row_box.append(&btn_play); + row_box.append(&lbl_title); + row_box.append(&lbl_time); + row_box.append(&lbl_path); + row_box.append(&btn_first); + row_box.append(&btn_last); + + let row = ListBoxRow::new(); + row.set_child(Some(&row_box)); + + // ── Doble clic izquierdo: agregar al final de playlist4 ────────────────── + { + let path_owned = path.to_path_buf(); + let click = GestureClick::new(); + click.set_button(1); + click.connect_released(move |_, n_press, _, _| { + if n_press == 2 { + append_to_playlist(&path_owned); + } + }); + row.add_controller(click); + } + + // ── Clic derecho: popover con CUE y Agregar ────────────────────────────── + { + let path_rc = Rc::new(path.to_path_buf()); + let title_rc = title.clone(); + let right = GestureClick::new(); + right.set_button(3); + right.connect_released(move |gesture, _, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + + let pop = gtk::Popover::new(); + pop.set_has_arrow(false); + pop.set_autohide(true); + pop.set_pointing_to(Some(&gdk4::Rectangle::new(x as i32, y as i32, 1, 1))); + + let vbox = GtkBox::new(Orientation::Vertical, 0); + vbox.set_margin_top(2); vbox.set_margin_bottom(2); + vbox.set_margin_start(2); vbox.set_margin_end(2); + + let btn_cue = Button::with_label(tr("buscador.menu_cue")); + let btn_add = Button::with_label(tr("buscador.menu_add")); + btn_cue.add_css_class("context-menu-item"); + btn_add.add_css_class("context-menu-item"); + vbox.append(&btn_cue); + vbox.append(&btn_add); + pop.set_child(Some(&vbox)); + + // Anclar al row + if let Some(w) = gesture.widget() { + pop.set_parent(&w); + } + + // CUE → ventana auxiliar GStreamer con seek, pausa, stop + let path_cue = path_rc.clone(); + let title_cue = title_rc.clone(); + let pop_cue = pop.clone(); + btn_cue.connect_clicked(move |_| { + pop_cue.popdown(); + abrir_ventana_cue_buscador(&path_cue, &title_cue); + }); + + // Agregar a playlist + let path_add = path_rc.clone(); + let pop_add = pop.clone(); + btn_add.connect_clicked(move |_| { + pop_add.popdown(); + append_to_playlist(&path_add); + }); + + pop.popup(); + }); + row.add_controller(right); + } + + // ── DragSource: arrastar URI para soltar en main ────────────────────────── + // FileList (GDK_TYPE_FILE_LIST) en lugar de for_bytes: el GType se anuncia + // directamente y los DropTarget(FileList) del scroll/frame aceptan la zona + // expandida sin necesidad de deserializar primero el MIME type. + let drag_src = gtk4::DragSource::new(); + drag_src.set_actions(gdk4::DragAction::COPY); + let gio_file = gtk4::gio::File::for_path(path); + let file_list = gdk4::FileList::from_array(&[gio_file]); + drag_src.connect_prepare(move |_, _, _| { + Some(ContentProvider::for_value(&file_list.to_value())) + }); + row.add_controller(drag_src); + + row +} + + +fn abrir_ventana_cue(path: &std::path::Path, title: &str) { + // Leer tarjeta CUE de gradio.config (línea 2) + let cue_dev = dirs::home_dir() + .and_then(|h| std::fs::read_to_string(h.join(".gradio/data/tmp/gradio.config")).ok()) + .and_then(|s| s.lines().nth(1).map(|l| l.trim().to_string())) + .filter(|s| !s.is_empty()); + + let uri = format!("file://{}", path.display()); + + // Construir pipeline GStreamer + gstreamer::init().unwrap_or(()); + let pipeline = gstreamer::Pipeline::new(); + let src = match gstreamer::ElementFactory::make("uridecodebin") + .property("uri", &uri).build() { Ok(e) => e, Err(_) => return }; + let convert = match gstreamer::ElementFactory::make("audioconvert").build() { Ok(e) => e, Err(_) => return }; + let resample = match gstreamer::ElementFactory::make("audioresample").build() { Ok(e) => e, Err(_) => return }; + let sink = match cue_dev { + Some(ref dev) => gstreamer::ElementFactory::make("pulsesink") + .property("device", dev.as_str()).build() + .unwrap_or_else(|_| gstreamer::ElementFactory::make("autoaudiosink").build().unwrap()), + None => match gstreamer::ElementFactory::make("autoaudiosink").build() { Ok(e) => e, Err(_) => return }, + }; + if pipeline.add_many([&src, &convert, &resample, &sink]).is_err() { return; } + if gstreamer::Element::link_many([&convert, &resample, &sink]).is_err() { return; } + let convert_w = convert.downgrade(); + src.connect_pad_added(move |_, pad| { + if let Some(c) = convert_w.upgrade() { + if let Some(sp) = c.static_pad("sink") { + if !sp.is_linked() { let _ = pad.link(&sp); } + } + } + }); + let _ = pipeline.set_state(gstreamer::State::Playing); + let pipeline = Rc::new(pipeline); + + // Ventana auxiliar + let win = gtk::Window::new(); + win.set_title(Some(&format!("🎧 CUE: {}", title))); + win.set_default_size(340, 90); + win.set_resizable(false); + + let vbox = gtk::Box::new(gtk::Orientation::Vertical, 8); + vbox.set_margin_top(12); vbox.set_margin_bottom(12); + vbox.set_margin_start(12); vbox.set_margin_end(12); + + let seek = gtk::Scale::new(gtk::Orientation::Horizontal, + Some(>k::Adjustment::new(0.0, 0.0, 100.0, 1.0, 5.0, 0.0))); + seek.set_hexpand(true); + seek.set_draw_value(false); + let time_lbl = gtk::Label::new(Some("00:00 / 00:00")); + + let time_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + time_row.append(&seek); + time_row.append(&time_lbl); + + let btn_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + btn_row.set_halign(gtk::Align::Center); + let btn_pause = Button::with_label(tr("buscador.btn_pause")); + let btn_stop = Button::with_label(tr("buscador.btn_stop")); + btn_row.append(&btn_pause); + btn_row.append(&btn_stop); + vbox.append(&time_row); + vbox.append(&btn_row); + win.set_child(Some(&vbox)); + + // Pausa/resume + let pipe_p = pipeline.clone(); + btn_pause.connect_clicked(move |btn| { + match pipe_p.current_state() { + gstreamer::State::Playing => { + let _ = pipe_p.set_state(gstreamer::State::Paused); + btn.set_label(tr("buscador.btn_resume")); + } + _ => { + let _ = pipe_p.set_state(gstreamer::State::Playing); + btn.set_label(tr("buscador.btn_pause")); + } + } + }); + + // Stop + let pipe_s = pipeline.clone(); + let win_s = win.clone(); + btn_stop.connect_clicked(move |_| { + let _ = pipe_s.set_state(gstreamer::State::Null); + win_s.close(); + }); + + // Cerrar = detener + let pipe_cl = pipeline.clone(); + win.connect_close_request(move |_| { + let _ = pipe_cl.set_state(gstreamer::State::Null); + glib::Propagation::Proceed + }); + + // Timer: posición + EOS + let pipe_t = pipeline.clone(); + let seek_c = seek.clone(); + let time_c = time_lbl.clone(); + let win_t = win.clone(); + let pipe_t2 = pipeline.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(300), move || { + let pos = pipe_t.query_position::() + .map(|t| t.seconds() as f64).unwrap_or(0.0); + let dur = pipe_t.query_duration::() + .map(|t| t.seconds() as f64).unwrap_or(1.0).max(1.0); + seek_c.set_range(0.0, dur); + seek_c.set_value(pos); + let fmt = |s: f64| format!("{:02}:{:02}", s as u64 / 60, s as u64 % 60); + time_c.set_text(&format!("{} / {}", fmt(pos), fmt(dur))); + if let Some(bus) = pipe_t.bus() { + while let Some(msg) = bus.pop() { + if let gstreamer::MessageView::Eos(_) = msg.view() { + let _ = pipe_t2.set_state(gstreamer::State::Null); + win_t.close(); + return glib::ControlFlow::Break; + } + } + } + if !win_t.is_visible() { return glib::ControlFlow::Break; } + glib::ControlFlow::Continue + }); + + // Seek manual + let pipe_sk = pipeline.clone(); + seek.connect_change_value(move |_, _, v| { + let _ = pipe_sk.seek_simple( + gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::KEY_UNIT, + gstreamer::ClockTime::from_seconds(v as u64), + ); + glib::Propagation::Proceed + }); + + win.present(); +} diff --git a/src/gr_playlist.rs b/src/gr_playlist.rs new file mode 100644 index 0000000..fa6cf71 --- /dev/null +++ b/src/gr_playlist.rs @@ -0,0 +1,866 @@ +// gr_playlist.rs — Editor de playlists G Radio (.gradio) +// +// Formato .gradio = mismo que playlist4: +// /ruta/archivo.mp3\tMM:SS.000 +// +// Funciones: +// - Agregar archivos por drag & drop o FileChooser +// - Reordenar con botones ↑ ↓ +// - Eliminar entradas +// - Borrar lista completa +// - Cargar lista existente +// - Guardar lista como .gradio +// - Muestra tiempo total acumulado + +use gtk4 as gtk; +use gtk::prelude::*; +use gtk::{ + Application, ApplicationWindow, Box as GtkBox, Button, Label, + ListBox, ListBoxRow, Orientation, ScrolledWindow, + CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, + FileChooserAction, +}; +use gdk4::FileList; + +const ICONO_PLAYLIST: &[u8] = include_bytes!("../assets/playlist48x48.png"); +use std::cell::RefCell; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::rc::Rc; + +use grpautaje::i18n::tr; +use grpautaje::skin; + +const APP_ID: &str = "com.gradio.playlist"; + +// ── Modelo de una entrada ──────────────────────────────────────────────────── +#[derive(Clone, Debug)] +struct Entry { + path: PathBuf, + title: String, + duration: String, // "MM:SS.000" + secs: f64, +} + +impl Entry { + fn from_path(path: PathBuf) -> Self { + let title = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Audio") + .to_string(); + let (duration, secs) = probe_duration(&path); + Entry { path, title, duration, secs } + } + + fn from_line(line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { return None; } + let (path_str, dur_str) = if let Some(pos) = line.find('\t') { + (line[..pos].trim(), line[pos+1..].trim()) + } else { + (line, "") + }; + if path_str.is_empty() { return None; } + let path = PathBuf::from(path_str); + let title = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Audio") + .to_string(); + let secs = parse_duration_str(dur_str); + let duration = if dur_str.is_empty() { + probe_duration(&path).0 + } else { + dur_str.to_string() + }; + Some(Entry { path, title, duration, secs }) + } + + fn to_line(&self) -> String { + format!("{}\t{}\n", self.path.display(), self.duration) + } +} + +fn parse_duration_str(s: &str) -> f64 { + let parts: Vec<&str> = s.splitn(2, ':').collect(); + if parts.len() == 2 { + let m: f64 = parts[0].parse().unwrap_or(0.0); + let s2: f64 = parts[1].trim_end_matches(".000") + .trim_end_matches(".mmm") + .parse().unwrap_or(0.0); + m * 60.0 + s2 + } else { + s.parse().unwrap_or(0.0) + } +} + +fn probe_duration(path: &Path) -> (String, f64) { + let out = Command::new("ffprobe") + .args(["-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + path.to_str().unwrap_or("")]) + .output(); + let secs = match out { + Ok(o) => String::from_utf8_lossy(&o.stdout) + .trim().parse::().unwrap_or(0.0), + Err(_) => 0.0, + }; + let total = secs as u64; + let dur = format!("{:02}:{:02}.000", total / 60, total % 60); + (dur, secs) +} + +fn format_total(secs: f64) -> String { + let t = secs as u64; + format!("{:02}:{:02}:{:02}", t / 3600, (t % 3600) / 60, t % 60) +} + +fn is_audio(p: &Path) -> bool { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| matches!(e.to_ascii_lowercase().as_str(), + "mp3" | "wav" | "ogg" | "flac" | "m4a" | "mp4" | "avi" | "mkv")) + .unwrap_or(false) +} + +// ── CSS ────────────────────────────────────────────────────────────────────── +const APP_CSS: &str = r#" +window { background-color: #1e1e1e; color: #e0e0e0; } + +.toolbar { + background-color: #2a2a2a; + border-bottom: 1px solid #444; + padding: 4px 8px; +} + +.total-label { + color: #88cc88; + font-size: 13px; + font-weight: bold; + padding: 0 12px; +} + +.header-row { + background-color: #2a3a4a; + padding: 4px 8px; + border-bottom: 1px solid #555; +} + +.header-label { + color: #aaccee; + font-weight: bold; + font-size: 12px; +} + +.pl-row { + padding: 3px 6px; + border-bottom: 1px solid #2a2a2a; + background-color: #1e1e1e; +} + +.pl-row:nth-child(even) { background-color: #252525; } +.pl-row:hover { background-color: #2a4a6a; } +.pl-row:selected { background-color: #1a6496; } + +.col-num { color: #888888; font-size: 11px; min-width: 32px; } +.col-title { color: #e0e0e0; font-size: 12px; } +.col-time { color: #88cc88; font-size: 12px; min-width: 80px; } +.col-path { color: #666666; font-size: 11px; } + +.btn-toolbar { + background: #3a3a3a; + color: #e0e0e0; + border: 1px solid #555; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + min-width: 32px; + min-height: 32px; +} +.btn-toolbar:hover { background: #4a90d9; color: #ffffff; } + +.btn-danger { + background: #4a1a1a; + color: #ff8888; + border: 1px solid #7a2a2a; + border-radius: 4px; + padding: 4px 10px; + font-size: 12px; + min-height: 32px; +} +.btn-danger:hover { background: #cc0000; color: #ffffff; } + +.btn-action { + background: #2a3a4a; + color: #88ccee; + border: 1px solid #3a5a7a; + border-radius: 4px; + padding: 4px 16px; + font-size: 12px; + min-height: 32px; +} +.btn-action:hover { background: #3a6a9a; color: #ffffff; } + +.bottom-bar { + background-color: #2a2a2a; + border-top: 1px solid #444; + padding: 6px 8px; +} + +.drop-hint { + color: #555555; + font-size: 13px; + font-style: italic; +} +"#; + +fn main() -> glib::ExitCode { + let locale_cfg = read_locale_from_config(); + grpautaje::i18n::init(locale_cfg.as_deref()); + + let app = Application::builder() + .application_id(APP_ID) + .build(); + app.connect_activate(|app| { + if let Some(w) = app.active_window() { w.present(); return; } + build_ui(app); + }); + app.run() +} + +fn read_locale_from_config() -> Option { + let home = dirs::home_dir()?; + let path = home.join(".gradio/data/gradio_config"); + let content = std::fs::read_to_string(path).ok()?; + let line = content.lines().nth(16)?.trim(); + if line.is_empty() { None } else { Some(line.to_string()) } +} + +fn build_ui(app: &Application) { + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_application_prefer_dark_theme(true); + } + + let provider = CssProvider::new(); + provider.load_from_data(APP_CSS); + if let Some(display) = gdk4::Display::default() { + gtk::style_context_add_provider_for_display( + &display, &provider, + STYLE_PROVIDER_PRIORITY_APPLICATION + 200, + ); + skin::aplicar_css_extra(&display); + } + + let window = ApplicationWindow::builder() + .application(app) + .title(tr("pl.win_title")) + .default_width(1200) + .default_height(700) + .build(); + + // Modelo compartido + let model: Rc>> = Rc::new(RefCell::new(Vec::new())); + // Archivo actual (para guardar) + let current_file: Rc>> = Rc::new(RefCell::new(None)); + + // ── Layout ─────────────────────────────────────────────────────────────── + let vbox = GtkBox::new(Orientation::Vertical, 0); + + // ── Barra superior ──────────────────────────────────────────────────────── + let toolbar = GtkBox::new(Orientation::Horizontal, 6); + toolbar.add_css_class("toolbar"); + toolbar.set_margin_top(4); toolbar.set_margin_bottom(4); + toolbar.set_margin_start(6); toolbar.set_margin_end(6); + + let btn_up = Button::with_label("▲"); + let btn_down = Button::with_label("▼"); + let btn_del = Button::with_label("🗑"); + let btn_play_cue = Button::with_label("♪"); + let btn_stop_cue = Button::with_label("⊗"); + let btn_clock = Button::with_label("⏱"); + + for btn in [&btn_up, &btn_down, &btn_del, &btn_play_cue, &btn_stop_cue, &btn_clock] { + btn.add_css_class("btn-toolbar"); + } + btn_del.set_tooltip_text(Some(tr("pl.tip_del"))); + btn_up.set_tooltip_text(Some(tr("pl.tip_up"))); + btn_down.set_tooltip_text(Some(tr("pl.tip_down"))); + btn_play_cue.set_tooltip_text(Some(tr("pl.tip_play_cue"))); + btn_stop_cue.set_tooltip_text(Some(tr("pl.tip_stop_cue"))); + btn_clock.set_tooltip_text(Some(tr("pl.tip_clock"))); + + let total_lbl = Label::new(Some("00:00:00")); + total_lbl.add_css_class("total-label"); + total_lbl.set_hexpand(true); + total_lbl.set_halign(gtk::Align::Start); + + toolbar.append(&btn_up); + toolbar.append(&btn_down); + toolbar.append(&btn_del); + toolbar.append(&btn_play_cue); + toolbar.append(&btn_stop_cue); + toolbar.append(&btn_clock); + toolbar.append(&total_lbl); + + // ── Cabecera columnas ───────────────────────────────────────────────────── + let header = GtkBox::new(Orientation::Horizontal, 0); + header.add_css_class("header-row"); + for (text, width, expand) in [ + (tr("pl.col_tema"), 400, true), + (tr("pl.col_tiempo"), 90, false), + (tr("pl.col_ruta"), -1, true), + ] { + let lbl = Label::new(Some(text)); + lbl.add_css_class("header-label"); + lbl.set_halign(gtk::Align::Start); + lbl.set_margin_start(6); + if width > 0 { lbl.set_size_request(width, -1); } + if expand { lbl.set_hexpand(true); } + header.append(&lbl); + } + + // ── Lista ───────────────────────────────────────────────────────────────── + let listbox = ListBox::new(); + listbox.set_selection_mode(gtk::SelectionMode::Single); + + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_child(Some(&listbox)); + + // Drop target: aceptar archivos desde gestor de archivos y desde gr-buscador. + // Se instalan tres DropTargets sobre el listbox Y sobre el scroll para cubrir + // tanto las filas como el área vacía debajo de la última fila: + // - STRING → text/uri-list del buscador (file://...) + // - FileList → múltiples archivos desde Nemo/Thunar/Nautilus + // - gio::File → un solo archivo desde un file manager + install_drop_targets(&listbox.clone().upcast::(), + &model, &listbox, &total_lbl); + install_drop_targets(&scroll.clone().upcast::(), + &model, &listbox, &total_lbl); + + // ── Barra inferior ──────────────────────────────────────────────────────── + let bottom = GtkBox::new(Orientation::Horizontal, 0); + bottom.add_css_class("bottom-bar"); + + let btn_clear = Button::with_label(tr("pl.btn_clear")); + let btn_load = Button::with_label(tr("pl.btn_load")); + let btn_save = Button::with_label(tr("pl.btn_save")); + + btn_clear.add_css_class("btn-danger"); + btn_load.add_css_class("btn-action"); + btn_save.add_css_class("btn-action"); + + btn_clear.set_hexpand(true); + btn_load.set_hexpand(true); + btn_save.set_hexpand(true); + + bottom.append(&btn_clear); + bottom.append(&btn_load); + bottom.append(&btn_save); + + vbox.append(&toolbar); + vbox.append(&header); + vbox.append(&scroll); + vbox.append(&bottom); + window.set_child(Some(&vbox)); + + // ── Señales ─────────────────────────────────────────────────────────────── + + // Subir + { + let model_u = model.clone(); + let listbox_u = listbox.clone(); + let total_u = total_lbl.clone(); + btn_up.connect_clicked(move |_| { + if let Some(row) = listbox_u.selected_row() { + let idx = row.index() as usize; + if idx == 0 { return; } + let mut m = model_u.borrow_mut(); + m.swap(idx, idx - 1); + drop(m); + rebuild_list(&model_u, &listbox_u); + if let Some(r) = listbox_u.row_at_index((idx - 1) as i32) { + listbox_u.select_row(Some(&r)); + } + update_total(&model_u, &total_u); + } + }); + } + + // Bajar + { + let model_d = model.clone(); + let listbox_d = listbox.clone(); + let total_d = total_lbl.clone(); + btn_down.connect_clicked(move |_| { + if let Some(row) = listbox_d.selected_row() { + let idx = row.index() as usize; + let len = model_d.borrow().len(); + if idx + 1 >= len { return; } + let mut m = model_d.borrow_mut(); + m.swap(idx, idx + 1); + drop(m); + rebuild_list(&model_d, &listbox_d); + if let Some(r) = listbox_d.row_at_index((idx + 1) as i32) { + listbox_d.select_row(Some(&r)); + } + update_total(&model_d, &total_d); + } + }); + } + + // Eliminar + { + let model_e = model.clone(); + let listbox_e = listbox.clone(); + let total_e = total_lbl.clone(); + btn_del.connect_clicked(move |_| { + if let Some(row) = listbox_e.selected_row() { + let idx = row.index() as usize; + model_e.borrow_mut().remove(idx); + rebuild_list(&model_e, &listbox_e); + update_total(&model_e, &total_e); + } + }); + } + + // Calcular tiempos (re-probe todos) + { + let model_c = model.clone(); + let total_c = total_lbl.clone(); + btn_clock.connect_clicked(move |_| { + let mut m = model_c.borrow_mut(); + for entry in m.iter_mut() { + let (dur, secs) = probe_duration(&entry.path); + entry.duration = dur; + entry.secs = secs; + } + let total: f64 = m.iter().map(|e| e.secs).sum(); + total_c.set_text(&format_total(total)); + }); + } + + // Pre-escucha (CUE) + let cue_proc: Rc>> = Rc::new(RefCell::new(None)); + { + let model_p = model.clone(); + let listbox_p = listbox.clone(); + let cue_p = cue_proc.clone(); + btn_play_cue.connect_clicked(move |_| { + if let Some(row) = listbox_p.selected_row() { + let idx = row.index() as usize; + let m = model_p.borrow(); + if let Some(entry) = m.get(idx) { + // Detener pre-escucha anterior + if let Some(mut child) = cue_p.borrow_mut().take() { + let _ = child.kill(); + } + // Usar mpv o ffplay para CUE + let child = Command::new("mpv") + .arg("--no-video") + .arg(entry.path.to_str().unwrap_or("")) + .spawn() + .or_else(|_| Command::new("ffplay") + .args(["-nodisp", "-autoexit"]) + .arg(entry.path.to_str().unwrap_or("")) + .spawn()); + if let Ok(c) = child { + *cue_p.borrow_mut() = Some(c); + } + } + } + }); + } + + { + let cue_s = cue_proc.clone(); + btn_stop_cue.connect_clicked(move |_| { + if let Some(mut child) = cue_s.borrow_mut().take() { + let _ = child.kill(); + } + }); + } + + // Borrar lista + { + let model_cl = model.clone(); + let listbox_cl = listbox.clone(); + let total_cl = total_lbl.clone(); + let cf_cl = current_file.clone(); + btn_clear.connect_clicked(move |_| { + model_cl.borrow_mut().clear(); + while let Some(ch) = listbox_cl.first_child() { listbox_cl.remove(&ch); } + total_cl.set_text("00:00:00"); + *cf_cl.borrow_mut() = None; + }); + } + + // Cargar lista + { + let model_lo = model.clone(); + let listbox_lo = listbox.clone(); + let total_lo = total_lbl.clone(); + let cf_lo = current_file.clone(); + let win_lo = window.clone(); + btn_load.connect_clicked(move |_| { + let fc = gtk::FileChooserDialog::new( + Some(tr("pl.load_title")), + Some(&win_lo), + FileChooserAction::Open, + &[(tr("btn.cancel"), gtk::ResponseType::Cancel), + (tr("btn.open"), gtk::ResponseType::Accept)], + ); + fc.set_modal(true); + let filter = gtk::FileFilter::new(); + filter.set_name(Some(tr("pl.filter"))); + filter.add_pattern("*.gradio"); + fc.add_filter(&filter); + + let model_c = model_lo.clone(); + let listbox_c = listbox_lo.clone(); + let total_c = total_lo.clone(); + let cf_c = cf_lo.clone(); + fc.connect_response(move |fc, resp| { + if resp == gtk::ResponseType::Accept { + if let Some(file) = fc.file() { + if let Some(path) = file.path() { + load_gradio_file(&path, &model_c, &listbox_c, &total_c); + *cf_c.borrow_mut() = Some(path); + } + } + } + fc.close(); + }); + fc.present(); + }); + } + + // Guardar lista + { + let model_sv = model.clone(); + let cf_sv = current_file.clone(); + let win_sv = window.clone(); + btn_save.connect_clicked(move |_| { + let fc = gtk::FileChooserDialog::new( + Some(tr("pl.save_title")), + Some(&win_sv), + FileChooserAction::Save, + &[(tr("btn.cancel"), gtk::ResponseType::Cancel), + (tr("btn.save"), gtk::ResponseType::Accept)], + ); + fc.set_modal(true); + fc.set_current_name(tr("pl.default_name")); + + // Si hay archivo actual, navegar a su carpeta + if let Some(ref cur) = *cf_sv.borrow() { + if let Some(parent) = cur.parent() { + let _ = fc.set_current_folder( + Some(>k4::gio::File::for_path(parent)) + ); + } + } + + let filter = gtk::FileFilter::new(); + filter.set_name(Some(tr("pl.filter"))); + filter.add_pattern("*.gradio"); + fc.add_filter(&filter); + + let model_c = model_sv.clone(); + let cf_c = cf_sv.clone(); + fc.connect_response(move |fc, resp| { + if resp == gtk::ResponseType::Accept { + if let Some(file) = fc.file() { + if let Some(mut path) = file.path() { + if path.extension().and_then(|e| e.to_str()) != Some("gradio") { + path.set_extension("gradio"); + } + save_gradio_file(&path, &model_c.borrow()); + *cf_c.borrow_mut() = Some(path); + } + } + } + fc.close(); + }); + fc.present(); + }); + } + + // Doble clic en fila → pre-escucha rápida + { + let model_dc = model.clone(); + let listbox_dc = listbox.clone(); + let click = gtk::GestureClick::new(); + click.set_button(1); + click.connect_released(move |_, n, _, _| { + if n == 2 { + if let Some(row) = listbox_dc.selected_row() { + let idx = row.index() as usize; + let m = model_dc.borrow(); + if let Some(entry) = m.get(idx) { + let _ = Command::new("mpv") + .arg("--no-video") + .arg(entry.path.to_str().unwrap_or("")) + .spawn() + .or_else(|_| Command::new("ffplay") + .args(["-nodisp", "-autoexit"]) + .arg(entry.path.to_str().unwrap_or("")) + .spawn()); + } + } + } + }); + listbox.add_controller(click); + } + + // Si se pasa un argumento, abrir ese archivo directamente + { + let args: Vec = std::env::args().collect(); + if let Some(path_str) = args.get(1) { + let path = PathBuf::from(path_str); + if path.exists() { + load_gradio_file(&path, &model, &listbox, &total_lbl); + *current_file.borrow_mut() = Some(path); + } + } + } + + // Ícono en taskbar + { + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + let _ = loader.write(&skin::icono("playlist48x48.png", ICONO_PLAYLIST)); + let _ = loader.close(); + if let Some(pb) = loader.pixbuf() { + let tex = gdk4::Texture::for_pixbuf(&pb); + window.connect_realize(move |w| { + if let Some(surf) = w.surface() { + use gdk4::prelude::ToplevelExt; + if let Some(tl) = surf.dynamic_cast_ref::() { + tl.set_icon_list(&[tex.clone()]); + } + } + }); + } + } + + // Tamaño dinámico según resolución + { + let win = window.clone(); + window.connect_realize(move |w| { + if let Some(surf) = w.surface() { + let display = gtk4::prelude::WidgetExt::display(w); + use gdk4::prelude::DisplayExt; + if let Some(monitor) = display.monitor_at_surface(&surf) { + use gdk4::prelude::MonitorExt; + let geo = monitor.geometry(); + let ancho = (geo.width() - 20).min(1300).max(600); + let alto = (geo.height() - 90).min(680).max(400); + win.set_default_size(ancho, alto); + } + } + }); + } + window.present(); +} + +// ── Funciones de modelo / UI ────────────────────────────────────────────────── + +/// Procesa una lista de rutas: las inserta en el modelo y la UI. +/// Devuelve `true` si se agregó al menos una entrada. +fn ingest_paths( + paths: Vec, + model: &Rc>>, + listbox: &ListBox, + total_lbl: &Label, +) -> bool { + let mut added = false; + for path in paths { + if path.is_dir() { + collect_audio_from_dir(&path, model, listbox); + added = true; + } else if is_audio(&path) { + let entry = Entry::from_path(path); + append_row(&entry, listbox); + model.borrow_mut().push(entry); + added = true; + } + } + if added { update_total(model, total_lbl); } + added +} + +/// Instala 3 DropTargets (STRING / FileList / gio::File) sobre `widget`, +/// para aceptar drops desde gr-buscador y file managers. +fn install_drop_targets( + widget: >k::Widget, + model: &Rc>>, + listbox: &ListBox, + total_lbl: &Label, +) { + // STRING — text/uri-list (buscador, fallback de file managers) + let drop_string = gtk4::DropTarget::new(glib::Type::STRING, gdk4::DragAction::COPY); + { + let model_d = model.clone(); + let listbox_d = listbox.clone(); + let total_d = total_lbl.clone(); + drop_string.connect_drop(move |_, value, _, _| { + let text = match value.get::() { + Ok(t) => t, + Err(_) => return false, + }; + let paths: Vec = text.lines() + .map(|l| l.trim().trim_start_matches("file://").to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + if paths.is_empty() { return false; } + ingest_paths(paths, &model_d, &listbox_d, &total_d) + }); + } + widget.add_controller(drop_string); + + // FileList — múltiples archivos desde Nemo/Thunar/Nautilus + let drop_files = gtk4::DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY); + { + let model_d = model.clone(); + let listbox_d = listbox.clone(); + let total_d = total_lbl.clone(); + drop_files.connect_drop(move |_, value, _, _| { + if let Ok(file_list) = value.get::() { + let paths: Vec = file_list.files() + .into_iter() + .filter_map(|f| f.path()) + .collect(); + if paths.is_empty() { return false; } + return ingest_paths(paths, &model_d, &listbox_d, &total_d); + } + false + }); + } + widget.add_controller(drop_files); + + // gio::File — un solo archivo desde un file manager + let drop_file = gtk4::DropTarget::new(gtk4::gio::File::static_type(), gdk4::DragAction::COPY); + { + let model_d = model.clone(); + let listbox_d = listbox.clone(); + let total_d = total_lbl.clone(); + drop_file.connect_drop(move |_, value, _, _| { + if let Ok(file) = value.get::() { + if let Some(path) = file.path() { + return ingest_paths(vec![path], &model_d, &listbox_d, &total_d); + } + } + false + }); + } + widget.add_controller(drop_file); +} + +fn append_row(entry: &Entry, listbox: &ListBox) { + let idx = listbox.observe_children().n_items() as usize + 1; + let row = build_row(idx, entry); + listbox.append(&row); +} + +fn build_row(num: usize, entry: &Entry) -> ListBoxRow { + let row_box = GtkBox::new(Orientation::Horizontal, 0); + row_box.add_css_class("pl-row"); + + let lbl_num = Label::new(Some(&format!("{}", num))); + let lbl_title = Label::new(Some(&entry.title)); + let lbl_time = Label::new(Some(&entry.duration)); + let lbl_path = Label::new(Some( + entry.path.parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default() + .as_str() + )); + + lbl_num.add_css_class("col-num"); + lbl_num.set_size_request(32, -1); + lbl_num.set_halign(gtk::Align::End); + lbl_num.set_margin_end(6); + + lbl_title.add_css_class("col-title"); + lbl_title.set_size_request(400, -1); + lbl_title.set_halign(gtk::Align::Start); + lbl_title.set_ellipsize(gtk::pango::EllipsizeMode::End); + + lbl_time.add_css_class("col-time"); + lbl_time.set_size_request(90, -1); + lbl_time.set_halign(gtk::Align::Start); + + lbl_path.add_css_class("col-path"); + lbl_path.set_hexpand(true); + lbl_path.set_halign(gtk::Align::Start); + lbl_path.set_ellipsize(gtk::pango::EllipsizeMode::Start); + + row_box.append(&lbl_num); + row_box.append(&lbl_title); + row_box.append(&lbl_time); + row_box.append(&lbl_path); + + let row = ListBoxRow::new(); + row.set_child(Some(&row_box)); + row +} + +fn rebuild_list(model: &Rc>>, listbox: &ListBox) { + while let Some(ch) = listbox.first_child() { listbox.remove(&ch); } + let m = model.borrow(); + for (i, entry) in m.iter().enumerate() { + listbox.append(&build_row(i + 1, entry)); + } +} + +fn update_total(model: &Rc>>, lbl: &Label) { + let total: f64 = model.borrow().iter().map(|e| e.secs).sum(); + lbl.set_text(&format_total(total)); +} + +fn collect_audio_from_dir( + dir: &Path, + model: &Rc>>, + listbox: &ListBox, +) { + let Ok(entries) = fs::read_dir(dir) else { return }; + let mut paths: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && is_audio(p)) + .collect(); + paths.sort(); + for path in paths { + let entry = Entry::from_path(path); + append_row(&entry, listbox); + model.borrow_mut().push(entry); + } +} + +fn load_gradio_file( + path: &Path, + model: &Rc>>, + listbox: &ListBox, + total_lbl: &Label, +) { + let Ok(content) = fs::read_to_string(path) else { return }; + model.borrow_mut().clear(); + while let Some(ch) = listbox.first_child() { listbox.remove(&ch); } + for line in content.lines() { + if let Some(entry) = Entry::from_line(line) { + append_row(&entry, listbox); + model.borrow_mut().push(entry); + } + } + update_total(model, total_lbl); +} + +fn save_gradio_file(path: &Path, entries: &[Entry]) { + let content: String = entries.iter().map(|e| e.to_line()).collect(); + if let Err(e) = fs::write(path, &content) { + eprintln!("[gr-playlist] Error guardando {:?}: {}", path, e); + } else { + eprintln!("[gr-playlist] Guardado: {:?} ({} temas)", path, entries.len()); + } +} diff --git a/src/gr_record.rs b/src/gr_record.rs new file mode 100644 index 0000000..5c5f633 --- /dev/null +++ b/src/gr_record.rs @@ -0,0 +1,640 @@ +// gr_record.rs — Grabador de audio G Radio +// +// Graba desde cualquier fuente PulseAudio (línea, monitor, etc.) +// Guarda en ~/GR-grabaciones/GRadio-rec-YYYY-MM-DD-HH-MM.mp3 +// VU meter (estilo player) activo solo durante grabación. + +use gtk4 as gtk; +use gtk::prelude::*; +use gtk::{ + Application, ApplicationWindow, Box as GtkBox, Button, Label, + ComboBoxText, DrawingArea, Orientation, + CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, + Image, +}; +use glib; +use gdk4; +use gstreamer as gst; + +const ICONO_RECORD: &[u8] = include_bytes!("../assets/grabar48x48.png"); +use grpautaje::skin; +use gstreamer::prelude::*; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +#[cfg(not(target_os = "windows"))] +use std::process::Command; +use chrono::Local; + +const APP_ID: &str = "com.gradio.record"; + +// ── Estado compartido entre GStreamer bus-watch y GTK ──────────────────────── +struct RecState { + vu_l: f64, + vu_r: f64, +} + +type SharedState = Arc>; + +fn main() -> glib::ExitCode { + gst::init().expect("GStreamer init falló"); + let app = Application::builder() + .application_id(APP_ID) + .build(); + app.connect_activate(|app| { + if let Some(w) = app.active_window() { w.present(); return; } + build_ui(app); + }); + app.run() +} + +// ── Listar fuentes de audio ─────────────────────────────────────────────────── +#[cfg(target_os = "windows")] +fn list_pulse_sources() -> Vec<(String, String)> { + vec![("default".to_string(), "Default Audio Input (WASAPI)".to_string())] +} + +#[cfg(not(target_os = "windows"))] +fn list_pulse_sources() -> Vec<(String, String)> { + let out = Command::new("pactl") + .args(["list", "sources"]) + .env("LANG", "C") + .output(); + let Ok(out) = out else { return vec![] }; + let text = String::from_utf8_lossy(&out.stdout); + + let mut sources: Vec<(String, String)> = Vec::new(); + let mut cur_name = String::new(); + let mut cur_desc = String::new(); + + for line in text.lines() { + let t = line.trim(); + if t.starts_with("Name:") { + cur_name = t.trim_start_matches("Name:").trim().to_string(); + cur_desc.clear(); + } else if t.starts_with("Description:") { + cur_desc = t.trim_start_matches("Description:").trim().to_string(); + if !cur_name.is_empty() { + sources.push((cur_name.clone(), cur_desc.clone())); + } + } + } + sources +} + +// ── Construir pipeline GStreamer ────────────────────────────────────────────── +fn build_pipeline(source_name: &str, output_path: &str) -> anyhow::Result { + let pipeline = gst::Pipeline::new(); + + #[cfg(target_os = "windows")] + let audio_src = gst::ElementFactory::make("wasapi2src") + .build() + .or_else(|_| gst::ElementFactory::make("autoaudiosrc").build()) + .map_err(|e| anyhow::anyhow!("audio source: {}", e))?; + + #[cfg(not(target_os = "windows"))] + let audio_src = gst::ElementFactory::make("pulsesrc") + .property("device", source_name) + .build() + .map_err(|e| anyhow::anyhow!("pulsesrc: {}", e))?; + + let audioconvert = gst::ElementFactory::make("audioconvert") + .build() + .map_err(|e| anyhow::anyhow!("audioconvert: {}", e))?; + + let audioresample = gst::ElementFactory::make("audioresample") + .build() + .map_err(|e| anyhow::anyhow!("audioresample: {}", e))?; + + let level_elem = gst::ElementFactory::make("level") + .property("interval", 50_000_000u64) // 50 ms + .property("peak-ttl", 0u64) + .property("post-messages", true) + .build() + .unwrap_or_else(|_| gst::ElementFactory::make("identity").build().unwrap()); + + let encoder = gst::ElementFactory::make("lamemp3enc") + .property("bitrate", 192i32) + .build() + .unwrap_or_else(|_| { + gst::ElementFactory::make("avenc_mp3").build() + .unwrap_or_else(|_| gst::ElementFactory::make("identity").build().unwrap()) + }); + + let filesink = gst::ElementFactory::make("filesink") + .property("location", output_path) + .build() + .map_err(|e| anyhow::anyhow!("filesink: {}", e))?; + + pipeline.add_many([ + &audio_src, &audioconvert, &audioresample, + &level_elem, &encoder, &filesink, + ]).map_err(|e| anyhow::anyhow!("add_many: {}", e))?; + + gst::Element::link_many([ + &audio_src, &audioconvert, &audioresample, + &level_elem, &encoder, &filesink, + ]).map_err(|e| anyhow::anyhow!("link_many: {}", e))?; + + Ok(pipeline) +} + +// ── Bus watch: alimenta el estado VU ───────────────────────────────────────── +fn attach_level_watch(pipeline: &gst::Pipeline, state: SharedState) { + let bus = match pipeline.bus() { + Some(b) => b, + None => return, + }; + let watch = bus.add_watch_local(move |_, msg| { + if let gst::MessageView::Element(elem) = msg.view() { + if let Some(structure) = elem.structure() { + if structure.name() == "level" { + let to_display = |db: f64| -> f64 { + if db <= -60.0 { return 0.0; } + let min_db = -50.0_f64; + let max_db = -3.0_f64; + ((db - min_db) / (max_db - min_db)).clamp(0.0, 1.0) + }; + let chs: Vec = if let Ok(arr) = structure.get::("rms") { + arr.iter().filter_map(|v| v.get::().ok()).collect() + } else { + vec![] + }; + if !chs.is_empty() { + if let Ok(mut st) = state.try_lock() { + st.vu_l = to_display(chs[0]); + st.vu_r = to_display(*chs.get(1).unwrap_or(&chs[0])); + } + } + } + } + } + glib::ControlFlow::Continue + }); + if let Ok(guard) = watch { + std::mem::forget(guard); + } +} + +// ── CSS ────────────────────────────────────────────────────────────────────── +const APP_CSS: &str = r#" +window { background-color: #1e1e1e; color: #e0e0e0; } + +.toolbar { + background-color: #2a2a2a; + border-bottom: 1px solid #444; + padding: 6px 10px; +} + +.source-label { + color: #aaccee; + font-weight: bold; + font-size: 13px; + padding-right: 6px; +} + +.vu-row { + background-color: #181818; + padding: 6px 10px; + border-bottom: 1px solid #333; +} + +.vu-ch-label { + color: #888; + font-size: 11px; + font-weight: bold; + min-width: 14px; +} + +.vu-gradio-label { + color: #cc3333; + font-size: 11px; + font-weight: bold; + padding-left: 8px; +} + +.info-bar { + background-color: #252525; + border-top: 1px solid #3a3a3a; + padding: 4px 10px; +} + +.rec-name-label { + color: #aaaaaa; + font-size: 12px; + padding-right: 8px; +} + +.rec-name-entry { + background-color: #2a2a2a; + color: #e0e0e0; + border: 1px solid #444; + border-radius: 3px; + font-size: 12px; +} + +.rec-time-label { + color: #88cc88; + font-size: 13px; + font-weight: bold; + font-family: monospace; + padding: 0 12px; +} + +.btn-rec { + background: #2a2a2a; + border: 1px solid #555; + border-radius: 4px; + padding: 4px; + min-width: 48px; + min-height: 48px; +} +.btn-rec:hover { background: #3a3a3a; } + +.btn-rec.recording { + background: #3a0000; + border-color: #880000; +} + +.bottom-bar { + background-color: #1e1e1e; + border-top: 1px solid #333; + padding: 8px 10px; +} +"#; + +// ── UI ──────────────────────────────────────────────────────────────────────── +fn build_ui(app: &Application) { + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_application_prefer_dark_theme(true); + } + + let provider = CssProvider::new(); + provider.load_from_data(APP_CSS); + if let Some(display) = gdk4::Display::default() { + gtk::style_context_add_provider_for_display( + &display, &provider, + STYLE_PROVIDER_PRIORITY_APPLICATION + 200, + ); + skin::aplicar_css_extra(&display); + } + + let window = ApplicationWindow::builder() + .application(app) + .title("GRadio Record") + .default_width(700) + .default_height(200) + .resizable(false) + .build(); + + // ── Estado ──────────────────────────────────────────────────────────────── + let recording: Rc> = Rc::new(Cell::new(false)); + let pipeline_holder: Rc>> = Rc::new(RefCell::new(None)); + let vu_state: SharedState = Arc::new(Mutex::new(RecState { vu_l: 0.0, vu_r: 0.0 })); + + let vu_l_draw = Rc::new(Cell::new(0.0f64)); + let vu_r_draw = Rc::new(Cell::new(0.0f64)); + let rec_active_draw = Rc::new(Cell::new(false)); + + // Segundos de grabación + let rec_secs: Rc> = Rc::new(Cell::new(0)); + + // ── Root ────────────────────────────────────────────────────────────────── + let root = GtkBox::new(Orientation::Vertical, 0); + + // ── Barra de fuente ─────────────────────────────────────────────────────── + let toolbar = GtkBox::new(Orientation::Horizontal, 8); + toolbar.add_css_class("toolbar"); + + let lbl_src = Label::new(Some("Fuente:")); + lbl_src.add_css_class("source-label"); + + let combo_src = ComboBoxText::new(); + combo_src.set_hexpand(true); + + // Poblar fuentes PulseAudio + let sources = list_pulse_sources(); + for (name, desc) in &sources { + combo_src.append(Some(name.as_str()), desc.as_str()); + } + if !sources.is_empty() { + combo_src.set_active(Some(0)); + } + + // Botón refrescar fuentes + let btn_refresh = Button::with_label("⟳"); + btn_refresh.set_tooltip_text(Some("Actualizar fuentes")); + { + let combo_r = combo_src.clone(); + btn_refresh.connect_clicked(move |_| { + combo_r.remove_all(); + for (name, desc) in list_pulse_sources() { + combo_r.append(Some(name.as_str()), desc.as_str()); + } + combo_r.set_active(Some(0)); + }); + } + + toolbar.append(&lbl_src); + toolbar.append(&combo_src); + toolbar.append(&btn_refresh); + root.append(&toolbar); + + // ── VU Meter ────────────────────────────────────────────────────────────── + const VU_LEDS: i32 = 24; + const VU_LED_W: f64 = 15.0; + const VU_LED_H: f64 = 8.0; + const VU_LED_GAP: f64 = 2.0; + + fn draw_vu_leds(cr: &cairo::Context, _w: i32, h: i32, level: f64, active: bool) { + let n = VU_LEDS; + let y = ((h as f64) - VU_LED_H) / 2.0; + let lit = if active { (level * n as f64).round() as i32 } else { 0 }; + for i in 0..n { + let x = i as f64 * (VU_LED_W + VU_LED_GAP); + let on = i < lit; + let (r, g, b) = if i >= (n as f64 * 0.75) as i32 { + if on { (1.0, 0.07, 0.0) } else { (0.25, 0.02, 0.0) } + } else if i >= (n as f64 * 0.55) as i32 { + if on { (0.9, 0.78, 0.0) } else { (0.22, 0.19, 0.0) } + } else { + if on { (0.0, 0.87, 0.13) } else { (0.0, 0.17, 0.03) } + }; + cr.set_source_rgb(r, g, b); + let rad = 1.5_f64; + cr.new_sub_path(); + cr.arc(x + VU_LED_W - rad, y + rad, rad, -std::f64::consts::PI / 2.0, 0.0); + cr.arc(x + VU_LED_W - rad, y + VU_LED_H - rad, rad, 0.0, std::f64::consts::PI / 2.0); + cr.arc(x + rad, y + VU_LED_H - rad, rad, std::f64::consts::PI / 2.0, std::f64::consts::PI); + cr.arc(x + rad, y + rad, rad, std::f64::consts::PI, 3.0 * std::f64::consts::PI / 2.0); + cr.close_path(); + let _ = cr.fill(); + } + } + + let vu_bar_l = DrawingArea::new(); + let vu_bar_r = DrawingArea::new(); + vu_bar_l.set_hexpand(true); + vu_bar_r.set_hexpand(true); + vu_bar_l.set_content_height(14); + vu_bar_r.set_content_height(14); + + { + let lev = vu_l_draw.clone(); + let act = rec_active_draw.clone(); + vu_bar_l.set_draw_func(move |_, cr, w, h| draw_vu_leds(cr, w, h, lev.get(), act.get())); + } + { + let lev = vu_r_draw.clone(); + let act = rec_active_draw.clone(); + vu_bar_r.set_draw_func(move |_, cr, w, h| draw_vu_leds(cr, w, h, lev.get(), act.get())); + } + + let lbl_l = Label::new(Some("L")); + let lbl_r = Label::new(Some("R")); + lbl_l.add_css_class("vu-ch-label"); + lbl_r.add_css_class("vu-ch-label"); + + let row_l = GtkBox::new(Orientation::Horizontal, 2); + row_l.append(&lbl_l); + row_l.append(&vu_bar_l); + + let row_r = GtkBox::new(Orientation::Horizontal, 2); + row_r.append(&lbl_r); + row_r.append(&vu_bar_r); + + let gradio_lbl = Label::new(Some("G-Radio")); + gradio_lbl.add_css_class("vu-gradio-label"); + gradio_lbl.set_valign(gtk::Align::Center); + gradio_lbl.set_halign(gtk::Align::End); + + let vu_bars = GtkBox::new(Orientation::Vertical, 0); + vu_bars.set_hexpand(true); + vu_bars.append(&row_l); + vu_bars.append(&row_r); + + let vu_row = GtkBox::new(Orientation::Horizontal, 8); + vu_row.add_css_class("vu-row"); + vu_row.append(&vu_bars); + vu_row.append(&gradio_lbl); + root.append(&vu_row); + + // ── Barra de info (nombre archivo + tiempo) ─────────────────────────────── + let info_bar = GtkBox::new(Orientation::Horizontal, 0); + info_bar.add_css_class("info-bar"); + + let lbl_grabacion = Label::new(Some("Grabación:")); + lbl_grabacion.add_css_class("rec-name-label"); + + // Entry de solo-lectura que muestra el nombre del archivo actual + let entry_name = gtk::Entry::new(); + entry_name.set_editable(false); + entry_name.set_hexpand(true); + entry_name.add_css_class("rec-name-entry"); + entry_name.set_placeholder_text(Some("(sin grabación activa)")); + + let lbl_time = Label::new(Some("00:00:00")); + lbl_time.add_css_class("rec-time-label"); + + info_bar.append(&lbl_grabacion); + info_bar.append(&entry_name); + info_bar.append(&lbl_time); + root.append(&info_bar); + + // ── Barra inferior (botón grabar) ───────────────────────────────────────── + let bottom_bar = GtkBox::new(Orientation::Horizontal, 0); + bottom_bar.add_css_class("bottom-bar"); + bottom_bar.set_halign(gtk::Align::End); + + // Icono rec (mismo que el player) + let icon_off = load_rec_icon(false); + let btn_rec = Button::new(); + btn_rec.set_child(Some(&icon_off)); + btn_rec.add_css_class("btn-rec"); + btn_rec.set_tooltip_text(Some("Grabar / Detener")); + + bottom_bar.append(&btn_rec); + root.append(&bottom_bar); + + window.set_child(Some(&root)); + + // ── Timer: refresca VU y tiempo ─────────────────────────────────────────── + { + let vu_state_t = vu_state.clone(); + let vu_l_t = vu_l_draw.clone(); + let vu_r_t = vu_r_draw.clone(); + let act_t = rec_active_draw.clone(); + let bar_l_t = vu_bar_l.clone(); + let bar_r_t = vu_bar_r.clone(); + let recording_t = recording.clone(); + + glib::timeout_add_local(std::time::Duration::from_millis(80), move || { + let is_rec = recording_t.get(); + act_t.set(is_rec); + + if is_rec { + if let Ok(st) = vu_state_t.try_lock() { + vu_l_t.set(st.vu_l); + vu_r_t.set(st.vu_r); + } + } else { + // Decay rápido cuando no graba + let decay = (vu_l_t.get() - 0.05).max(0.0); + vu_l_t.set(decay); + let decay = (vu_r_t.get() - 0.05).max(0.0); + vu_r_t.set(decay); + } + bar_l_t.queue_draw(); + bar_r_t.queue_draw(); + glib::ControlFlow::Continue + }); + + // Segundo timer para el contador de tiempo (1 s) + let recording_s = recording.clone(); + let rec_secs_s = rec_secs.clone(); + let lbl_time_s = lbl_time.clone(); + glib::timeout_add_local(std::time::Duration::from_secs(1), move || { + if recording_s.get() { + let s = rec_secs_s.get() + 1; + rec_secs_s.set(s); + lbl_time_s.set_text(&format!("{:02}:{:02}:{:02}", s / 3600, (s % 3600) / 60, s % 60)); + } + glib::ControlFlow::Continue + }); + } + + // ── Acción del botón grabar ─────────────────────────────────────────────── + { + let recording_b = recording.clone(); + let pipeline_b = pipeline_holder.clone(); + let vu_state_b = vu_state.clone(); + let combo_b = combo_src.clone(); + let entry_b = entry_name.clone(); + let lbl_time_b = lbl_time.clone(); + let rec_secs_b = rec_secs.clone(); + let btn_rec_b = btn_rec.clone(); + let vu_l_b = vu_l_draw.clone(); + let vu_r_b = vu_r_draw.clone(); + + btn_rec.connect_clicked(move |_| { + if recording_b.get() { + // ── DETENER ────────────────────────────────────────────────── + if let Some(pipe) = pipeline_b.borrow_mut().take() { + let _ = pipe.send_event(gst::event::Eos::new()); + // Esperar EOS breve + let _ = pipe.state(gst::ClockTime::from_seconds(2)); + let _ = pipe.set_state(gst::State::Null); + } + recording_b.set(false); + vu_l_b.set(0.0); + vu_r_b.set(0.0); + btn_rec_b.remove_css_class("recording"); + btn_rec_b.set_child(Some(&load_rec_icon(false))); + } else { + // ── INICIAR ─────────────────────────────────────────────────── + let source = match combo_b.active_id() { + Some(id) => id.to_string(), + None => { + eprintln!("[gr-record] Sin fuente seleccionada"); + return; + } + }; + + // Crear carpeta destino + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + let dest_dir = home.join("GR-grabaciones"); + if !dest_dir.exists() { + if let Err(e) = std::fs::create_dir_all(&dest_dir) { + eprintln!("[gr-record] No se pudo crear {}: {}", dest_dir.display(), e); + return; + } + } + + let filename = format!("GRadio-rec-{}.mp3", Local::now().format("%Y-%m-%d-%H-%M")); + let output_path = dest_dir.join(&filename); + let output_str = output_path.to_string_lossy().to_string(); + + match build_pipeline(&source, &output_str) { + Ok(pipe) => { + attach_level_watch(&pipe, vu_state_b.clone()); + if pipe.set_state(gst::State::Playing).is_err() { + eprintln!("[gr-record] Error iniciando pipeline"); + return; + } + *pipeline_b.borrow_mut() = Some(pipe); + recording_b.set(true); + rec_secs_b.set(0); + lbl_time_b.set_text("00:00:00"); + entry_b.set_text(&filename); + btn_rec_b.add_css_class("recording"); + btn_rec_b.set_child(Some(&load_rec_icon(true))); + } + Err(e) => { + eprintln!("[gr-record] Error creando pipeline: {:?}", e); + } + } + } + }); + } + + // Ícono en taskbar + { + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + let _ = loader.write(&skin::icono("grabar48x48.png", ICONO_RECORD)); + let _ = loader.close(); + if let Some(pb) = loader.pixbuf() { + let tex = gdk4::Texture::for_pixbuf(&pb); + let win = window.clone(); + window.connect_realize(move |w| { + if let Some(surf) = w.surface() { + use gdk4::prelude::ToplevelExt; + if let Some(tl) = surf.dynamic_cast_ref::() { + tl.set_icon_list(&[tex.clone()]); + } + let display = gtk4::prelude::WidgetExt::display(w); + use gdk4::prelude::DisplayExt; + if let Some(monitor) = display.monitor_at_surface(&surf) { + use gdk4::prelude::MonitorExt; + let geo = monitor.geometry(); + let ancho = (geo.width() - 20).min(720).max(400); + let alto = (geo.height() - 90).min(250).max(180); + win.set_default_size(ancho, alto); + } + } + }); + } + } + + window.present(); +} + +// ── Carga el ícono de grabar (on/off) ───────────────────────────────────────── +fn load_rec_icon(active: bool) -> Image { + let asset = if active { "assets/rec-on.png" } else { "assets/rec-off.png" }; + + // Intentar ruta relativa al binario + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + + for base in [ + std::env::current_dir().ok(), + exe_dir.as_ref().and_then(|d| d.parent().map(|p| p.to_path_buf())), + exe_dir, + ] + .into_iter() + .flatten() + { + let path = base.join(asset); + if path.exists() { + let img = Image::from_file(&path); + img.set_pixel_size(36); + return img; + } + } + + // Fallback: icono de tema del sistema + let img = Image::from_icon_name(if active { "media-record" } else { "media-record-symbolic" }); + img.set_pixel_size(36); + img +} diff --git a/src/gr_reportes.rs b/src/gr_reportes.rs new file mode 100644 index 0000000..3379d2d --- /dev/null +++ b/src/gr_reportes.rs @@ -0,0 +1,1217 @@ +//! gr_reportes — G Radio Reporting Tool +//! Genera reportes PDF y CSV de audios emitidos (comerciales, parrilla, eventos). + +use gtk4::prelude::*; + +const ICONO_REPORTES: &[u8] = include_bytes!("../assets/reportes.png"); +use gtk4::{ + Application, ApplicationWindow, Align, Box as GtkBox, Button, Calendar, CheckButton, + Entry, Label, ListBox, ListBoxRow, Orientation, Popover, ScrolledWindow, + CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, Separator, +}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::fs; +use chrono::{Datelike, Local, NaiveDate, Duration as ChronoDuration}; +use grpautaje::i18n::tr; +use grpautaje::skin; + +const LOGO_BYTES: &[u8] = include_bytes!("../assets/gradio.png"); +const APP_CSS: &str = r#" +/* ── Forzar tema oscuro en todos los widgets ── */ +window, .background { + background-color: #12121e; + color: #d8e0f0; +} +box, scrolledwindow, viewport { + background-color: #12121e; + color: #d8e0f0; +} +listbox { + background-color: #12121e; + color: #d8e0f0; +} +listbox row { + background-color: #12121e; + color: #d8e0f0; +} +listbox row:hover { + background-color: #1a1a2e; +} +separator { + background-color: #2a3060; + min-width: 1px; + min-height: 1px; +} +label { color: #d8e0f0; } +checkbutton { color: #d8e0f0; } +popover contents { + background-color: #1a1e38; + color: #d8e0f0; +} +calendar { + background-color: #1a1e38; + color: #d8e0f0; + border: 1px solid #3a55aa; + border-radius: 4px; +} +calendar:selected { + background-color: #2a4a8a; + color: white; +} +.header-bar { + background-color: #0e1428; + padding: 10px 16px; + border-bottom: 2px solid #1e3a70; +} +.title-lbl { font-size: 17px; font-weight: bold; color: #e0eeff; } +.sub-lbl { font-size: 11px; color: #7090c0; } +.sec-lbl { font-size: 10px; font-weight: bold; color: #8099cc; letter-spacing: 1px; } +.date-entry { + background-color: #1e2240; + color: #ccd8ff; + border: 1px solid #3a55aa; + border-radius: 4px; + padding: 4px 8px; + font-size: 13px; + min-width: 108px; +} +.date-entry:focus { border-color: #5577dd; } +checkbutton.cat { padding: 5px 10px; } +checkbutton.cat label { font-size: 13px; color: #b0c8ee; } +checkbutton.cat:checked label { color: #55aaff; font-weight: bold; } +.list-hdr { + background-color: #1a2040; + border-bottom: 1px solid #2a3a6a; + padding: 4px 8px; +} +.list-hdr label { font-size: 10px; font-weight: bold; color: #7090b0; letter-spacing: 1px; } +.res-name { font-size: 12px; color: #c8daff; } +.res-count { font-size: 12px; color: #55cc88; } +.res-dur { font-size: 12px; color: #aabb66; } +.status-lbl { font-size: 11px; color: #778899; padding: 4px 16px; } +.btn-load { + background-color: #1a4a80; + color: white; + font-weight: bold; + border-radius: 4px; + padding: 6px 14px; + font-size: 12px; +} +.btn-load:hover { background-color: #2060a0; } +.btn-pdf { + background-color: #8b1a1a; + color: white; + font-weight: bold; + border-radius: 4px; + padding: 7px 16px; + font-size: 12px; +} +.btn-pdf:hover { background-color: #b02020; } +.btn-pdf:disabled { background-color: #3a2020; color: #886666; } +.btn-csv { + background-color: #1a5c30; + color: white; + font-weight: bold; + border-radius: 4px; + padding: 7px 16px; + font-size: 12px; +} +.btn-csv:hover { background-color: #22793e; } +.btn-csv:disabled { background-color: #1a2c20; color: #557755; } +.btn-close { background-color: #383840; color: #c0c0d0; border-radius: 4px; padding: 7px 14px; font-size: 12px; } +.btn-close:hover { background-color: #484850; } +.dia-fecha { font-size: 12px; font-weight: bold; color: #88bbff; } +.dia-total { font-size: 12px; color: #d8e0f0; } +.dia-nac { font-size: 12px; color: #66dd88; } +.dia-int { font-size: 12px; color: #ff9944; } +"#; + +// ── Estructura de entrada de log ────────────────────────────────────────────── + +#[derive(Debug, Clone)] +struct Entrada { + fecha: String, // YYYY-MM-DD + hora: String, // HH:MM:SS + nombre: String, // solo nombre de archivo (con extensión) + ruta: String, // ruta absoluta completa (para clasificación) + duracion: u32, // segundos +} + +// ── Parseo de archivos de log ───────────────────────────────────────────────── + +fn parsear_archivo(path: &std::path::Path) -> Vec { + let contenido = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return vec![], + }; + let mut entradas = vec![]; + for linea in contenido.lines() { + let linea = linea.trim(); + if linea.is_empty() { continue; } + // Formato: {fecha},{hora},{ruta},{dur},seg,{tipo} + let campos: Vec<&str> = linea.splitn(6, ',').collect(); + if campos.len() < 4 { continue; } + let fecha = campos[0].to_string(); + let hora = campos[1].to_string(); + let ruta = campos[2].to_string(); + let duracion: u32 = campos[3].parse().unwrap_or(0); + let nombre = std::path::Path::new(&ruta) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(&ruta) + .to_string(); + entradas.push(Entrada { fecha, hora, nombre, ruta, duracion }); + } + entradas +} + +// Devuelve (carpetas_nacionales, carpetas_intercultural) +fn leer_carpetas_clasificacion() -> (Vec, Vec) { + let cfg_path = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/")) + .join(".gradio/data/tmp/gradio.config"); + let text = fs::read_to_string(&cfg_path).unwrap_or_default(); + let mut lines = text.lines(); + // saltar líneas 1-9 (main_dev, cue_dev, station_name, crossfade, pisador x4, silence) + for _ in 0..9 { lines.next(); } + let nacionales = lines.next() + .map(|l| parse_cfg_path_list(l.trim())) + .unwrap_or_default(); + let intercultural = lines.next() + .map(|l| parse_cfg_path_list(l.trim())) + .unwrap_or_default(); + (nacionales, intercultural) +} + +fn parse_cfg_path_list(s: &str) -> Vec { + let mut result = Vec::new(); + let mut rest = s; + while !rest.is_empty() { + rest = rest.trim_start(); + if rest.starts_with('"') { + rest = &rest[1..]; + if let Some(end) = rest.find('"') { + result.push(rest[..end].to_string()); + rest = &rest[end + 1..]; + rest = rest.trim_start_matches(';'); + } else { + result.push(rest.to_string()); + break; + } + } else if let Some(end) = rest.find(';') { + let item = rest[..end].trim().to_string(); + if !item.is_empty() { result.push(item); } + rest = &rest[end + 1..]; + } else { + let item = rest.trim().to_string(); + if !item.is_empty() { result.push(item); } + break; + } + } + result +} + +// Clasifica una ruta: true si su directorio padre empieza por alguno de los prefijos +fn es_de_carpetas(ruta: &str, carpetas: &[String]) -> bool { + if carpetas.is_empty() { return false; } + let dir = std::path::Path::new(ruta) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + carpetas.iter().any(|c| dir.starts_with(c.as_str()) || ruta.starts_with(c.as_str())) +} + +#[derive(Debug, Clone)] +struct ResumenDia { + fecha: String, + total_seg: u64, + nacional_seg: u64, + intercultural_seg:u64, + total_emisiones: usize, +} + +fn calcular_resumen_por_dia( + entradas: &[Entrada], + nacionales: &[String], + intercultural: &[String], +) -> Vec { + let mut mapa: BTreeMap = BTreeMap::new(); + for e in entradas { + let r = mapa.entry(e.fecha.clone()).or_insert(ResumenDia { + fecha: e.fecha.clone(), + total_seg: 0, + nacional_seg: 0, + intercultural_seg: 0, + total_emisiones: 0, + }); + r.total_seg += e.duracion as u64; + r.total_emisiones += 1; + if es_de_carpetas(&e.ruta, nacionales) { + r.nacional_seg += e.duracion as u64; + } + if es_de_carpetas(&e.ruta, intercultural) { + r.intercultural_seg += e.duracion as u64; + } + } + mapa.into_values().collect() +} + +fn leer_nombre_radio() -> String { + let cfg = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/")) + .join(".gradio/data/tmp/gradio.config"); + fs::read_to_string(&cfg) + .ok() + .and_then(|s| { + // Línea 1: main_dev, Línea 2: cue_dev, Línea 3: station_name + s.lines().nth(2).map(|l| l.trim().to_string()) + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "G Radio".to_string()) +} + +fn cargar_entradas(categoria: &str, desde: &str, hasta: &str) -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let dir = home.join(".gradio/data/reporte"); + + let prefijo = match categoria { + "Comerciales" => "GR6-comercial-", + "Parrilla" => "GR6-parrilla-", + "Eventos" => "GR6-evento-", + _ => return vec![], + }; + + let desde_d = NaiveDate::parse_from_str(desde, "%Y-%m-%d").ok(); + let hasta_d = NaiveDate::parse_from_str(hasta, "%Y-%m-%d").ok(); + + let mut todas = vec![]; + if let Ok(iter) = fs::read_dir(&dir) { + let mut archivos: Vec<_> = iter.flatten().collect(); + archivos.sort_by_key(|e| e.file_name()); + for ent in archivos { + let fname = ent.file_name(); + let fname = fname.to_string_lossy(); + if !fname.starts_with(prefijo) || !fname.ends_with(".txt") { continue; } + let fecha_str = &fname[prefijo.len()..fname.len()-4]; + if let Ok(fd) = NaiveDate::parse_from_str(fecha_str, "%Y-%m-%d") { + if desde_d.map(|d| fd < d).unwrap_or(false) { continue; } + if hasta_d.map(|h| fd > h).unwrap_or(false) { continue; } + } + let mut e = parsear_archivo(&ent.path()); + todas.append(&mut e); + } + } + todas +} + +// ── Agrupación: nombre → (dur_secs, fecha → [HH:MM, ...]) ───────────────────── + +type Agrupado = BTreeMap>)>; + +fn agrupar(entradas: &[Entrada]) -> Agrupado { + let mut mapa: Agrupado = BTreeMap::new(); + for e in entradas { + let hhmm = e.hora.chars().take(5).collect::(); + let ent = mapa.entry(e.nombre.clone()).or_insert((e.duracion, BTreeMap::new())); + ent.1.entry(e.fecha.clone()).or_default().push(hhmm); + } + mapa +} + +fn dur_hms(seg: u64) -> String { + let h = seg / 3600; + let m = (seg % 3600) / 60; + let s = seg % 60; + if h > 0 { format!("{}h {:02}m {:02}s", h, m, s) } + else { format!("{}m {:02}s", m, s) } +} + +// ── Generación de PDF con Cairo ─────────────────────────────────────────────── + +fn generar_pdf( + entradas: &[Entrada], + categoria: &str, + desde: &str, + hasta: &str, + destino: &std::path::Path, +) -> Result<(), Box> { + let nombre_radio = leer_nombre_radio(); + use cairo::{Context, FontSlant, FontWeight, PdfSurface}; + + // A4 en puntos (72 dpi) + let pw = 595.0_f64; + let ph = 842.0_f64; + let ml = 48.0_f64; // margen izquierdo + let mr = 48.0_f64; // margen derecho + let cw = pw - ml - mr; + let mt = 84.0_f64; // altura del header + let mb = 44.0_f64; // margen inferior + + let surface = PdfSurface::new(pw, ph, destino)?; + let cr = Context::new(&surface)?; + + let agrupado = agrupar(entradas); + let tot_tit = agrupado.len(); + let tot_emis = entradas.len(); + + let mut pagina = 1u32; + let mut y = mt; + + // ── Dibuja la cabecera de página ────────────────────────────────────────── + fn cabecera( + cr: &Context, + pw: f64, ml: f64, mr: f64, mt: f64, + pagina: u32, categoria: &str, desde: &str, hasta: &str, + nombre_radio: &str, + ) { + // Fondo del header + cr.set_source_rgb(0.07, 0.10, 0.22); + cr.rectangle(0.0, 0.0, pw, mt - 8.0); + let _ = cr.fill(); + + // Franja azul lateral izquierda + cr.set_source_rgb(0.15, 0.42, 0.78); + cr.rectangle(ml - 6.0, 6.0, 4.0, mt - 16.0); + let _ = cr.fill(); + + // Logo G-Radio (PNG embebido) + if let Ok(logo) = cairo::ImageSurface::create_from_png( + &mut std::io::Cursor::new(skin::icono("gradio.png", LOGO_BYTES)) + ) { + let lh = logo.height() as f64; + let target = (mt - 16.0).min(60.0); + let scale = target / lh; + cr.save().ok(); + cr.translate(ml + 2.0, (mt - 8.0 - target) / 2.0); + cr.scale(scale, scale); + cr.set_source_surface(&logo, 0.0, 0.0).ok(); + cr.paint().ok(); + cr.restore().ok(); + } + + // Título + cr.set_source_rgb(0.88, 0.94, 1.0); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Bold); + cr.set_font_size(14.5); + cr.move_to(ml + 70.0, 32.0); + let _ = cr.show_text("G Radio — Reporting Tool"); + + // Subtítulo + cr.set_source_rgb(0.55, 0.70, 0.92); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Normal); + cr.set_font_size(9.0); + cr.move_to(ml + 70.0, 46.0); + let _ = cr.show_text(&format!( + "Audios Emitidos · {} · {} al {}", categoria, desde, hasta + )); + + // Nombre de la radio + cr.set_source_rgb(0.80, 0.55, 1.0); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Bold); + cr.set_font_size(8.0); + cr.move_to(ml + 70.0, 58.0); + let _ = cr.show_text(nombre_radio); + + // Número de página + cr.set_source_rgb(0.55, 0.65, 0.82); + cr.set_font_size(8.5); + let pg = format!("Pág. {}", pagina); + if let Ok(te) = cr.text_extents(&pg) { + cr.move_to(pw - mr - te.width(), 38.0); + } + let _ = cr.show_text(&format!("Pág. {}", pagina)); + + // Línea divisora + cr.set_source_rgb(0.20, 0.46, 0.80); + cr.set_line_width(1.0); + cr.move_to(ml, mt - 8.0); + cr.line_to(pw - mr, mt - 8.0); + let _ = cr.stroke(); + } + + // Dibuja la primera página + cabecera(&cr, pw, ml, mr, mt, pagina, categoria, desde, hasta, &nombre_radio); + + let mut fila_idx = 0usize; + for (nombre, (dur_secs, fechas)) in &agrupado { + let tot_plays: usize = fechas.values().map(|v| v.len()).sum(); + let dur_total = *dur_secs as u64 * tot_plays as u64; + + // Espacio mínimo: banda título (18) + al menos 1 fila fecha (13) + separador (8) + let min_needed = 18.0 + 13.0 + 8.0; + if y + min_needed > ph - mb { + cr.show_page().ok(); + pagina += 1; + cabecera(&cr, pw, ml, mr, mt, pagina, categoria, desde, hasta, &nombre_radio); + y = mt; + } + + // ── Banda de nombre del audio ────────────────────────────────────── + let bg_h = if fila_idx % 2 == 0 { 0.11 } else { 0.13 }; + cr.set_source_rgb(bg_h, bg_h + 0.03, bg_h + 0.12); + cr.rectangle(ml, y, cw, 18.0); + cr.fill().ok(); + + // Nombre (negrita, blanco) + cr.set_source_rgb(0.92, 0.96, 1.0); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Bold); + cr.set_font_size(10.0); + cr.move_to(ml + 4.0, y + 13.0); + let max_chars = 68; + let nombre_display = if nombre.len() > max_chars { + format!("{}…", &nombre[..max_chars]) + } else { + nombre.clone() + }; + cr.show_text(&nombre_display).ok(); + + // Derecha: resumen + cr.set_source_rgb(0.45, 0.82, 0.55); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Normal); + cr.set_font_size(8.5); + let resumen = format!( + "{} emisiones · {}s c/u · {}", + tot_plays, dur_secs, dur_hms(dur_total) + ); + if let Ok(te) = cr.text_extents(&resumen) { + cr.move_to(pw - mr - te.width() - 4.0, y + 12.5); + } + cr.show_text(&resumen).ok(); + y += 18.0; + + // ── Filas de fechas ──────────────────────────────────────────────── + for (fi, (fecha, horas)) in fechas.iter().enumerate() { + if y + 13.0 > ph - mb { + cr.show_page().ok(); + pagina += 1; + cabecera(&cr, pw, ml, mr, mt, pagina, categoria, desde, hasta, &nombre_radio); + y = mt; + // Banner de continuación + cr.set_source_rgb(0.08, 0.12, 0.28); + cr.rectangle(ml, y, cw, 13.0); + cr.fill().ok(); + cr.set_source_rgb(0.55, 0.72, 0.95); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Bold); + cr.set_font_size(8.0); + cr.move_to(ml + 4.0, y + 9.5); + cr.show_text(&format!("{} (cont.)", nombre_display)).ok(); + y += 14.0; + } + + // Fondo alternado (blanco / gris muy claro) + let rbg = if fi % 2 == 0 { 0.975_f64 } else { 0.950_f64 }; + cr.set_source_rgb(rbg, rbg, rbg); + cr.rectangle(ml, y, cw, 13.0); + cr.fill().ok(); + + // Fecha DD/MM/YYYY (bold, azul) + let fecha_fmt = if let Ok(d) = NaiveDate::parse_from_str(fecha, "%Y-%m-%d") { + d.format("%d/%m/%Y").to_string() + } else { + fecha.clone() + }; + cr.set_source_rgb(0.10, 0.18, 0.55); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Bold); + cr.set_font_size(9.5); + cr.move_to(ml + 4.0, y + 10.0); + cr.show_text(&fecha_fmt).ok(); + + // Horas separadas por comas + cr.set_source_rgb(0.15, 0.15, 0.15); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Normal); + cr.set_font_size(9.0); + cr.move_to(ml + 74.0, y + 10.0); + cr.show_text(&horas.join(", ")).ok(); + + y += 13.0; + } + + // Línea divisora entre audios + cr.set_source_rgb(0.65, 0.65, 0.70); + cr.set_line_width(0.4); + cr.move_to(ml, y + 1.0); + cr.line_to(pw - mr, y + 1.0); + cr.stroke().ok(); + y += 7.0; + + fila_idx += 1; + } + + // ── Pie de reporte ───────────────────────────────────────────────────────── + if y + 22.0 > ph - mb { + cr.show_page().ok(); + pagina += 1; + cabecera(&cr, pw, ml, mr, mt, pagina, categoria, desde, hasta, &nombre_radio); + y = mt; + } + y += 6.0; + cr.set_source_rgb(0.50, 0.50, 0.55); + cr.set_line_width(0.7); + cr.move_to(ml, y); + cr.line_to(pw - mr, y); + cr.stroke().ok(); + y += 9.0; + + cr.set_source_rgb(0.42, 0.42, 0.48); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Normal); + cr.set_font_size(7.5); + cr.move_to(ml, y); + let ahora = Local::now(); + cr.show_text(&format!( + "Generado: {} · Títulos únicos: {} · Total emisiones: {}", + ahora.format("%d/%m/%Y %H:%M"), + tot_tit, + tot_emis, + )).ok(); + + // ── Página de Resumen por Día ───────────────────────────────────────────── + let (nacionales, intercultural) = leer_carpetas_clasificacion(); + let resumen_dias = calcular_resumen_por_dia(entradas, &nacionales, &intercultural); + if !resumen_dias.is_empty() { + cr.show_page().ok(); + pagina += 1; + cabecera(&cr, pw, ml, mr, mt, pagina, categoria, desde, hasta, &nombre_radio); + let mut ry = mt + 8.0; + + // Título de sección + cr.set_source_rgb(0.88, 0.94, 1.0); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Bold); + cr.set_font_size(11.0); + cr.move_to(ml, ry); + cr.show_text("Resumen por Día — Nacional e Intercultural").ok(); + ry += 16.0; + + // Cabecera de tabla + cr.set_source_rgb(0.13, 0.17, 0.38); + cr.rectangle(ml, ry, cw, 14.0); + cr.fill().ok(); + cr.set_source_rgb(0.65, 0.78, 1.0); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Bold); + cr.set_font_size(8.5); + let col_w = cw / 4.0; + cr.move_to(ml + 4.0, ry + 10.0); cr.show_text("FECHA").ok(); + cr.move_to(ml + col_w, ry + 10.0); cr.show_text("TOTAL").ok(); + cr.move_to(ml + col_w * 2.0, ry + 10.0); cr.show_text("NACIONAL").ok(); + cr.move_to(ml + col_w * 3.0, ry + 10.0); cr.show_text("INTERCULTURAL").ok(); + ry += 14.0; + + for (idx, rd) in resumen_dias.iter().enumerate() { + if ry + 13.0 > ph - mb { + cr.show_page().ok(); + pagina += 1; + cabecera(&cr, pw, ml, mr, mt, pagina, categoria, desde, hasta, &nombre_radio); + ry = mt; + } + let bg = if idx % 2 == 0 { 0.975_f64 } else { 0.950_f64 }; + cr.set_source_rgb(bg, bg, bg); + cr.rectangle(ml, ry, cw, 13.0); + cr.fill().ok(); + + let fecha_fmt = if let Ok(d) = NaiveDate::parse_from_str(&rd.fecha, "%Y-%m-%d") { + d.format("%d/%m/%Y").to_string() + } else { rd.fecha.clone() }; + + let pct_nac = if rd.total_seg > 0 { rd.nacional_seg * 100 / rd.total_seg } else { 0 }; + let pct_int = if rd.total_seg > 0 { rd.intercultural_seg * 100 / rd.total_seg } else { 0 }; + + cr.set_source_rgb(0.10, 0.10, 0.15); + cr.select_font_face("Sans", FontSlant::Normal, FontWeight::Normal); + cr.set_font_size(9.0); + cr.move_to(ml + 4.0, ry + 9.5); cr.show_text(&fecha_fmt).ok(); + cr.move_to(ml + col_w, ry + 9.5); cr.show_text(&dur_hms(rd.total_seg)).ok(); + + cr.set_source_rgb(0.10, 0.48, 0.22); + cr.move_to(ml + col_w * 2.0, ry + 9.5); + cr.show_text(&format!("{} ({} %)", dur_hms(rd.nacional_seg), pct_nac)).ok(); + + cr.set_source_rgb(0.60, 0.32, 0.05); + cr.move_to(ml + col_w * 3.0, ry + 9.5); + cr.show_text(&format!("{} ({} %)", dur_hms(rd.intercultural_seg), pct_int)).ok(); + + ry += 13.0; + } + } + + drop(cr); + surface.finish(); + Ok(()) +} + +// ── Exportación CSV ─────────────────────────────────────────────────────────── + +fn exportar_csv(entradas: &[Entrada], destino: &std::path::Path) -> Result<(), Box> { + let mut txt = String::from("Archivo,Fecha,Hora,Duracion_seg\n"); + for e in entradas { + // Sin ruta absoluta, sin "seg", sin tipo + txt.push_str(&format!("{},{},{},{}\n", + e.nombre, e.fecha, e.hora, e.duracion)); + } + fs::write(destino, &txt)?; + Ok(()) +} + +// ── Interfaz GTK4 ───────────────────────────────────────────────────────────── + +fn construir_ui(app: &Application) { + let win = ApplicationWindow::builder() + .application(app) + .title(tr("rep.win_title")) + .default_width(860) + .default_height(660) + .build(); + + // Solicitar tema oscuro al sistema de settings de GTK + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_application_prefer_dark_theme(true); + } + + // CSS con prioridad máxima para pisar el tema del sistema + let css = CssProvider::new(); + css.load_from_data(APP_CSS); + if let Some(display) = gdk4::Display::default() { + gtk4::style_context_add_provider_for_display( + &display, &css, gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + skin::aplicar_css_extra(&display); + } + + let root = GtkBox::new(Orientation::Vertical, 0); + + // ── Header ─────────────────────────────────────────────────────────────── + let hbar = GtkBox::new(Orientation::Horizontal, 12); + hbar.add_css_class("header-bar"); + hbar.set_margin_bottom(10); + + // Logo + { + let loader = gdk4::gdk_pixbuf::PixbufLoader::with_type("png") + .unwrap_or_else(|_| gdk4::gdk_pixbuf::PixbufLoader::new()); + loader.write(&skin::icono("gradio.png", LOGO_BYTES)).ok(); + loader.close().ok(); + if let Some(pb) = loader.pixbuf() { + if let Some(pb2) = pb.scale_simple(76, 76, gdk4::gdk_pixbuf::InterpType::Bilinear) { + let tex = gdk4::Texture::for_pixbuf(&pb2); + let img = gtk4::Image::from_paintable(Some(&tex)); + img.set_size_request(76, 76); + img.set_margin_end(8); + hbar.append(&img); + } + } + } + + let title_col = GtkBox::new(Orientation::Vertical, 2); + title_col.set_hexpand(true); + let t_lbl = Label::new(Some(tr("rep.title"))); + t_lbl.add_css_class("title-lbl"); + t_lbl.set_halign(Align::Start); + let s_lbl = Label::new(Some(tr("rep.subtitle"))); + s_lbl.add_css_class("sub-lbl"); + s_lbl.set_halign(Align::Start); + title_col.append(&t_lbl); + title_col.append(&s_lbl); + hbar.append(&title_col); + root.append(&hbar); + + // ── Controles ───────────────────────────────────────────────────────────── + let ctrl = GtkBox::new(Orientation::Horizontal, 20); + ctrl.set_margin_start(16); + ctrl.set_margin_end(16); + ctrl.set_margin_bottom(10); + + // Categoría + let cat_col = GtkBox::new(Orientation::Vertical, 5); + let cat_lbl = Label::new(Some(tr("rep.categoria"))); + cat_lbl.add_css_class("sec-lbl"); + cat_lbl.set_halign(Align::Start); + let btn_com = CheckButton::with_label(tr("rep.cat_com")); + let btn_par = CheckButton::with_label(tr("rep.cat_par")); + let btn_ev = CheckButton::with_label(tr("rep.cat_ev")); + btn_par.set_group(Some(&btn_com)); + btn_ev.set_group(Some(&btn_com)); + btn_com.set_active(true); + for b in [&btn_com, &btn_par, &btn_ev] { b.add_css_class("cat"); } + cat_col.append(&cat_lbl); + cat_col.append(&btn_com); + cat_col.append(&btn_par); + cat_col.append(&btn_ev); + + // Fechas + let date_col = GtkBox::new(Orientation::Vertical, 6); + let dl = Label::new(Some(tr("rep.rango_fechas"))); + dl.add_css_class("sec-lbl"); + dl.set_halign(Align::Start); + + let hoy = Local::now().date_naive(); + let hace30 = hoy - ChronoDuration::days(30); + + // Selector gráfico de fecha: botón con etiqueta de fecha + popover con Calendar + let mk_date_picker = |label: &str, fecha: NaiveDate| -> (GtkBox, Entry) { + let row = GtkBox::new(Orientation::Horizontal, 6); + let l = Label::new(Some(label)); + l.add_css_class("sec-lbl"); + l.set_width_chars(6); + + // Entry oculto que guarda la fecha seleccionada (AAAA-MM-DD) + let entry = Entry::builder() + .text(fecha.format("%Y-%m-%d").to_string().as_str()) + .max_length(10) + .build(); + entry.set_visible(false); // no visible — solo almacenamiento + + // Botón que muestra la fecha formateada y abre el calendario + let btn_lbl = fecha.format("%d/%m/%Y").to_string(); + let btn_fecha = Button::with_label(&btn_lbl); + btn_fecha.add_css_class("date-entry"); + btn_fecha.set_hexpand(false); + + // Popover con Calendar + let popover = Popover::new(); + let cal = Calendar::new(); + // Posicionar al día correcto + cal.set_year(fecha.year()); + cal.set_month(fecha.month0() as i32); + cal.set_day(fecha.day() as i32); + popover.set_child(Some(&cal)); + popover.set_parent(&btn_fecha); + + // Al hacer clic en el botón, abrir/cerrar el popover + { + let pop = popover.clone(); + btn_fecha.connect_clicked(move |_| pop.popup()); + } + // Al seleccionar un día, actualizar el Entry y el botón + { + let e = entry.clone(); + let bf = btn_fecha.clone(); + let pop = popover.clone(); + cal.connect_day_selected(move |c| { + let y = c.year(); + let m = c.month() + 1; // gtk Calendar: month0 (0-11) + let d = c.day(); + if let Some(nd) = NaiveDate::from_ymd_opt(y, m as u32, d as u32) { + e.set_text(&nd.format("%Y-%m-%d").to_string()); + bf.set_label(&nd.format("%d/%m/%Y").to_string()); + pop.popdown(); + } + }); + } + + row.append(&l); + row.append(&btn_fecha); + (row, entry) + }; + + let (desde_row, entry_desde) = mk_date_picker(tr("rep.desde"), hace30); + let (hasta_row, entry_hasta) = mk_date_picker(tr("rep.hasta"), hoy); + + let btn_cargar = Button::with_label(tr("rep.btn_cargar")); + btn_cargar.add_css_class("btn-load"); + + date_col.append(&dl); + date_col.append(&desde_row); + date_col.append(&hasta_row); + date_col.append(&btn_cargar); + + // Filtro de texto + let filter_col = GtkBox::new(Orientation::Vertical, 6); + let fl = Label::new(Some(tr("rep.buscar_audio"))); + fl.add_css_class("sec-lbl"); + fl.set_halign(Align::Start); + let entry_filtro = Entry::builder() + .placeholder_text(tr("rep.buscar_ph")) + .hexpand(true) + .build(); + entry_filtro.add_css_class("date-entry"); + let fl2 = Label::new(Some(tr("rep.buscar_hint"))); + fl2.add_css_class("status-lbl"); + fl2.set_halign(Align::Start); + filter_col.append(&fl); + filter_col.append(&entry_filtro); + filter_col.append(&fl2); + filter_col.set_hexpand(true); + + ctrl.append(&cat_col); + ctrl.append(&Separator::new(Orientation::Vertical)); + ctrl.append(&date_col); + ctrl.append(&Separator::new(Orientation::Vertical)); + ctrl.append(&filter_col); + root.append(&ctrl); + + // ── Lista de resultados ─────────────────────────────────────────────────── + let list_box = ListBox::new(); + list_box.set_selection_mode(gtk4::SelectionMode::None); + + let scroll = ScrolledWindow::builder() + .child(&list_box) + .vexpand(true) + .hexpand(true) + .margin_start(16) + .margin_end(16) + .margin_bottom(6) + .build(); + root.append(&scroll); + + // ── Resumen por Día ─────────────────────────────────────────────────────── + let dia_hdr_box = GtkBox::new(Orientation::Horizontal, 8); + dia_hdr_box.add_css_class("list-hdr"); + dia_hdr_box.set_margin_start(16); + dia_hdr_box.set_margin_end(16); + dia_hdr_box.set_margin_top(6); + let mk_dh = |txt: &str, chars: i32| { + let l = Label::new(Some(txt)); + l.add_css_class("sec-lbl"); + l.set_width_chars(chars); + l.set_halign(Align::Start); + l + }; + let dh_fecha = mk_dh(tr("rep.col_fecha"), 11); + let dh_tot = mk_dh(tr("rep.col_total"), 9); + let dh_nac = mk_dh(tr("rep.col_nacional"), 20); + let dh_int = mk_dh(tr("rep.col_intercult"), 22); + dia_hdr_box.append(&dh_fecha); + dia_hdr_box.append(&dh_tot); + dia_hdr_box.append(&dh_nac); + dia_hdr_box.append(&dh_int); + root.append(&dia_hdr_box); + + let dia_list = ListBox::new(); + dia_list.set_selection_mode(gtk4::SelectionMode::None); + let dia_scroll = ScrolledWindow::builder() + .child(&dia_list) + .hexpand(true) + .margin_start(16) + .margin_end(16) + .margin_bottom(4) + .build(); + // altura fija: máximo ~5 filas visibles + dia_scroll.set_min_content_height(120); + dia_scroll.set_max_content_height(180); + root.append(&dia_scroll); + + // ── Estado ─────────────────────────────────────────────────────────────── + let status = Label::new(Some(tr("rep.status_inicio"))); + status.add_css_class("status-lbl"); + status.set_halign(Align::Start); + root.append(&status); + + // ── Botones de acción ───────────────────────────────────────────────────── + let act_box = GtkBox::new(Orientation::Horizontal, 8); + act_box.set_margin_start(16); + act_box.set_margin_end(16); + act_box.set_margin_bottom(14); + + let btn_pdf = Button::with_label(tr("rep.btn_pdf")); + btn_pdf.add_css_class("btn-pdf"); + btn_pdf.set_sensitive(false); + + let btn_csv_btn = Button::with_label(tr("rep.btn_csv")); + btn_csv_btn.add_css_class("btn-csv"); + btn_csv_btn.set_sensitive(false); + + let spacer = Label::new(None); + spacer.set_hexpand(true); + + let btn_close = Button::with_label(tr("btn.close")); + btn_close.add_css_class("btn-close"); + + act_box.append(&btn_pdf); + act_box.append(&btn_csv_btn); + act_box.append(&spacer); + act_box.append(&btn_close); + root.append(&act_box); + + win.set_child(Some(&root)); + + // ── Estado compartido: entradas cargadas ────────────────────────────────── + let store: Arc>> = Arc::new(Mutex::new(vec![])); + + // ── Cargar datos ────────────────────────────────────────────────────────── + { + let lb = list_box.clone(); + let dl = dia_list.clone(); + let st = status.clone(); + let bp = btn_pdf.clone(); + let bc = btn_csv_btn.clone(); + let data = store.clone(); + let b_com = btn_com.clone(); + let b_par = btn_par.clone(); + let e_d = entry_desde.clone(); + let e_h = entry_hasta.clone(); + let e_f = entry_filtro.clone(); + + btn_cargar.connect_clicked(move |_| { + let cat = if b_com.is_active() { "Comerciales" } + else if b_par.is_active() { "Parrilla" } + else { "Eventos" }; + let desde = e_d.text().to_string(); + let hasta = e_h.text().to_string(); + let filtro = e_f.text().to_string().to_lowercase(); + + let mut entradas = cargar_entradas(cat, &desde, &hasta); + if !filtro.is_empty() { + entradas.retain(|e| e.nombre.to_lowercase().contains(&filtro)); + } + let agrupado = agrupar(&entradas); + + // Limpiar listas + while let Some(c) = lb.first_child() { lb.remove(&c); } + while let Some(c) = dl.first_child() { dl.remove(&c); } + + if entradas.is_empty() { + st.set_text(tr("rep.status_vacio")); + bp.set_sensitive(false); + bc.set_sensitive(false); + *data.lock().unwrap() = vec![]; + return; + } + + // Cabecera de columnas + { + let hdr = GtkBox::new(Orientation::Horizontal, 8); + hdr.add_css_class("list-hdr"); + hdr.set_margin_start(6); + hdr.set_margin_end(6); + hdr.set_margin_top(3); + hdr.set_margin_bottom(3); + let h1 = Label::new(Some(tr("rep.col_archivo"))); + h1.set_hexpand(true); + h1.set_halign(Align::Start); + h1.add_css_class("sec-lbl"); + let h2 = Label::new(Some(tr("rep.col_emisiones"))); + h2.set_width_chars(10); + h2.add_css_class("sec-lbl"); + let h3 = Label::new(Some(tr("rep.col_dur_total"))); + h3.set_width_chars(12); + h3.add_css_class("sec-lbl"); + hdr.append(&h1); + hdr.append(&h2); + hdr.append(&h3); + let row = ListBoxRow::new(); + row.set_child(Some(&hdr)); + row.set_activatable(false); + lb.append(&row); + } + + for (nombre, (dur_s, fechas)) in &agrupado { + let total: usize = fechas.values().map(|v| v.len()).sum(); + let dur_t = *dur_s as u64 * total as u64; + + let rb = GtkBox::new(Orientation::Horizontal, 8); + rb.set_margin_start(6); + rb.set_margin_end(6); + rb.set_margin_top(2); + rb.set_margin_bottom(2); + + let nl = Label::new(Some(nombre)); + nl.set_hexpand(true); + nl.set_halign(Align::Start); + nl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + nl.add_css_class("res-name"); + + let cl = Label::new(Some(&total.to_string())); + cl.set_width_chars(10); + cl.set_halign(Align::End); + cl.add_css_class("res-count"); + + let dl = Label::new(Some(&dur_hms(dur_t))); + dl.set_width_chars(12); + dl.set_halign(Align::End); + dl.add_css_class("res-dur"); + + rb.append(&nl); + rb.append(&cl); + rb.append(&dl); + + let row = ListBoxRow::new(); + row.set_child(Some(&rb)); + row.set_activatable(false); + lb.append(&row); + } + + // ── Resumen por día ────────────────────────────────────────── + let (nacionales, intercultural) = leer_carpetas_clasificacion(); + let resumen = calcular_resumen_por_dia(&entradas, &nacionales, &intercultural); + for rd in &resumen { + let rb = GtkBox::new(Orientation::Horizontal, 8); + rb.set_margin_start(6); rb.set_margin_end(6); + rb.set_margin_top(2); rb.set_margin_bottom(2); + + let fecha_fmt = if let Ok(d) = NaiveDate::parse_from_str(&rd.fecha, "%Y-%m-%d") { + d.format("%d/%m/%Y").to_string() + } else { rd.fecha.clone() }; + + let lf = Label::new(Some(&fecha_fmt)); + lf.set_width_chars(11); lf.set_halign(Align::Start); + lf.add_css_class("dia-fecha"); + + let lt = Label::new(Some(&dur_hms(rd.total_seg))); + lt.set_width_chars(9); lt.set_halign(Align::Start); + lt.add_css_class("dia-total"); + + let pct_nac = if rd.total_seg > 0 { + rd.nacional_seg * 100 / rd.total_seg + } else { 0 }; + let pct_int = if rd.total_seg > 0 { + rd.intercultural_seg * 100 / rd.total_seg + } else { 0 }; + + let ln = Label::new(Some(&format!("{} ({} %)", dur_hms(rd.nacional_seg), pct_nac))); + ln.set_width_chars(20); ln.set_halign(Align::Start); + ln.add_css_class("dia-nac"); + + let li = Label::new(Some(&format!("{} ({} %)", dur_hms(rd.intercultural_seg), pct_int))); + li.set_width_chars(22); li.set_halign(Align::Start); + li.add_css_class("dia-int"); + + rb.append(&lf); rb.append(<); rb.append(&ln); rb.append(&li); + let row = ListBoxRow::new(); + row.set_child(Some(&rb)); + row.set_activatable(false); + dl.append(&row); + } + + st.set_text( + &tr("rep.status_resumen") + .replace("{titulos}", &agrupado.len().to_string()) + .replace("{emisiones}", &entradas.len().to_string()) + .replace("{desde}", &desde) + .replace("{hasta}", &hasta) + ); + bp.set_sensitive(true); + bc.set_sensitive(true); + *data.lock().unwrap() = entradas; + }); + } + + // ── Generar PDF ─────────────────────────────────────────────────────────── + { + let data = store.clone(); + let st = status.clone(); + let b_com = btn_com.clone(); + let b_par = btn_par.clone(); + let e_d = entry_desde.clone(); + let e_h = entry_hasta.clone(); + + btn_pdf.connect_clicked(move |_| { + let cat = if b_com.is_active() { "Comerciales" } + else if b_par.is_active() { "Parrilla" } + else { "Eventos" }; + let desde = e_d.text().to_string(); + let hasta = e_h.text().to_string(); + + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let dl = home.join("Downloads"); + let base = if dl.exists() { dl } else { home }; + let fname = format!("GR6-reporte-{}-{}_{}.pdf", + cat.to_lowercase().replace(' ', "-"), desde, hasta); + let path = base.join(&fname); + + let entradas = data.lock().unwrap().clone(); + match generar_pdf(&entradas, cat, &desde, &hasta, &path) { + Ok(_) => { + st.set_text(&tr("rep.status_pdf_ok").replace("{path}", &path.display().to_string())); + #[cfg(target_os = "windows")] + let _ = std::process::Command::new("explorer").arg(&path).spawn(); + #[cfg(not(target_os = "windows"))] + let _ = std::process::Command::new("xdg-open").arg(&path).spawn(); + } + Err(e) => { + st.set_text(&tr("rep.status_pdf_err").replace("{err}", &e.to_string())); + } + } + }); + } + + // ── Exportar CSV ────────────────────────────────────────────────────────── + { + let data = store.clone(); + let st = status.clone(); + let b_com = btn_com.clone(); + let b_par = btn_par.clone(); + let e_d = entry_desde.clone(); + let e_h = entry_hasta.clone(); + + btn_csv_btn.connect_clicked(move |_| { + let cat = if b_com.is_active() { "Comerciales" } + else if b_par.is_active() { "Parrilla" } + else { "Eventos" }; + let desde = e_d.text().to_string(); + let hasta = e_h.text().to_string(); + + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let dl = home.join("Downloads"); + let base = if dl.exists() { dl } else { home }; + let fname = format!("GR6-reporte-{}-{}_{}.csv", + cat.to_lowercase().replace(' ', "-"), desde, hasta); + let path = base.join(&fname); + + let entradas = data.lock().unwrap().clone(); + match exportar_csv(&entradas, &path) { + Ok(_) => { + st.set_text(&tr("rep.status_csv_ok").replace("{path}", &path.display().to_string())); + } + Err(e) => { + st.set_text(&tr("rep.status_csv_err").replace("{err}", &e.to_string())); + } + } + }); + } + + // ── Cerrar ──────────────────────────────────────────────────────────────── + { + let w = win.clone(); + btn_close.connect_clicked(move |_| w.close()); + } + + // Ícono en taskbar + { + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + let _ = loader.write(&skin::icono("reportes.png", ICONO_REPORTES)); + let _ = loader.close(); + if let Some(pb) = loader.pixbuf() { + let tex = gdk4::Texture::for_pixbuf(&pb); + let win2 = win.clone(); + win.connect_realize(move |w| { + if let Some(surf) = w.surface() { + use gdk4::prelude::ToplevelExt; + if let Some(tl) = surf.dynamic_cast_ref::() { + tl.set_icon_list(&[tex.clone()]); + } + let display = gtk4::prelude::WidgetExt::display(w); + use gdk4::prelude::DisplayExt; + if let Some(monitor) = display.monitor_at_surface(&surf) { + use gdk4::prelude::MonitorExt; + let geo = monitor.geometry(); + let ancho = (geo.width() - 20).min(900).max(600); + let alto = (geo.height() - 90).min(680).max(400); + win2.set_default_size(ancho, alto); + } + } + }); + } + } + + win.show(); +} + +fn main() { + // i18n: leer locale del config compartido si está, si no autodetectar + let locale_cfg = read_locale_from_config(); + grpautaje::i18n::init(locale_cfg.as_deref()); + + let app = Application::builder() + .application_id("ar.com.gradio.reportes") + .build(); + app.connect_activate(construir_ui); + app.run(); +} + +fn read_locale_from_config() -> Option { + let home = dirs::home_dir()?; + let path = home.join(".gradio/data/gradio_config"); + let content = std::fs::read_to_string(path).ok()?; + let line = content.lines().nth(16)?.trim(); + if line.is_empty() { None } else { Some(line.to_string()) } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..fcbda47 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,999 @@ +//! Internacionalización — diccionarios embebidos es/en/pt. +//! +//! Se inicializa una sola vez en `main()` antes de construir la UI. Cambiar el +//! idioma en vivo requeriría reconstruir todos los widgets; en su lugar se le +//! pide al usuario reiniciar la aplicación tras cambiar el idioma. +//! +//! Fase 1 (radio-player v0.4.3): cubre títulos de ventanas, diálogos comunes, +//! botones de toolbar y labels más visibles. Strings de logs, errores internos +//! y sub-paneles más profundos quedan en español hasta una segunda iteración. + +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 { + match c { "es"=>Some(Locale::Es), "en"=>Some(Locale::En), "pt"=>Some(Locale::Pt), _=>None } + } +} + +static LOCALE: OnceLock = OnceLock::new(); + +/// Inicializa el locale activo. Prioridad: `forced` > env > `en`. +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 { + let raw = std::env::var("LC_ALL").ok().filter(|s| !s.is_empty()) + .or_else(|| std::env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty())) + .or_else(|| std::env::var("LANG").ok().filter(|s| !s.is_empty()))?; + let code = raw.split(['.', '_', '@']).next()?.to_lowercase(); + Locale::from_code(&code) +} + +#[inline] +pub fn current() -> Locale { + *LOCALE.get().unwrap_or(&Locale::En) +} + +/// Devuelve la traducción para `key` en el idioma activo. +/// Si no hay traducción cae a inglés, y si tampoco existe devuelve la key. +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 = &[ + // Títulos de ventana + ("win.player", "G Radio Player"), + ("win.pautaje", "GR Pautaje"), + ("win.processor", "GR Procesador DSP"), + ("win.config", "Configuración GR"), + // Botones comunes + ("btn.ok", "OK"), + ("btn.cancel", "Cancelar"), + ("btn.save", "Guardar"), + ("btn.close", "Cerrar"), + ("btn.interrupt", "Interrumpir"), + ("btn.generate", "Generar"), + ("btn.all", "Todos"), + ("btn.choose_folder", "Elegir carpeta"), + ("btn.edit_list", "✏ Editar lista…"), + ("btn.select", "Seleccionar"), + ("btn.operador", "🎛 Operador"), + // Display "now playing" + ("now.stopped_end", "⏹ Detenido al final del tema"), + ("now.app_name", "G Radio Player"), + ("now.no_track", "Sin tema"), + ("now.stopped_after", "Se detuvo: Detener después"), + ("now.no_ad", "Sin comercial"), + // Decks + ("deck.a", "🎵 Deck A (temas impares)"), + ("deck.b", "🎵 Deck B (temas pares)"), + // Tooltips de toolbar + ("tool.start", "Iniciar reproducción"), + ("tool.stop_general", "Stop General — detiene parrilla, comerciales y eventos"), + ("tool.next", "Siguiente tema"), + ("tool.hora", "Reproducir hora"), + ("tool.pisador", "Pisador"), + ("tool.duck", "Bajar música (duck)"), + ("tool.buscador", "Buscador de audio"), + ("tool.pautaje", "Pautaje"), + ("tool.parrilla", "Parrilla"), + ("tool.playlist", "Editor de playlists .gradio"), + ("tool.botonera", "Botonera"), + ("tool.config", "Configuración"), + ("tool.visor", "Visor"), + ("tool.record", "Grabar"), + ("tool.reportes", "Reportería de audios emitidos"), + // Cola activa / tandas de comerciales + ("cola.titulo", "⬛ Cola activa"), + ("cola.vaciar", "🗑 Vaciar"), + ("cola.no_tandas", "Sin tandas programadas en las próximas horas"), + // Volumen (status bar) + ("vol.fmt", "Vol: {up}% | Duck: {dn}% | Fundido: {mix}s"), + // Tooltips de configuración + ("tip.empty_queue", "Eliminar todos los comerciales en cola"), + ("tip.vu_meter", "VU Meter"), + ("tip.dsp_on", "Procesador DSP activo — clic para desactivar"), + ("tip.dsp_off", "Procesador DSP inactivo — clic para activar"), + ("tip.upvol", "Nivel de volumen del audio principal (0–100)."), + ("tip.downvol", "Nivel de volumen durante la atenuación (duck) por comerciales (0–100)."), + ("tip.client_port", "Puerto TCP en que gr-client se conectará a este servidor (default: 7777)."), + ("tip.client_token", "Token que deben enviar los clientes remotos al conectarse. Vacío = sin auth."), + ("tip.gen_id", "Genera un ID aleatorio de 8 dígitos."), + ("tip.silence", "Segundos de silencio sostenido antes de avanzar al siguiente tema. 0 = desactivado."), + ("tip.relay", "Activa la conexión al servidor de relay para acceso remoto desde internet."), + ("tip.relay_id", "ID de 8 dígitos que identifica este servidor en el relay. Compártelo con gr-client para conectarse por internet."), + // Labels del diálogo de Configuración + ("cfg.audio_main", "Tarjeta de audio principal:"), + ("cfg.audio_cue", "Tarjeta de audio CUE:"), + ("cfg.medio_name", "Nombre del medio:"), + ("cfg.crossfade", "Tiempo de fundido (seg):"), + ("cfg.pisador_on", "Pisador sobre temas:"), + ("cfg.pisador_dir", "Carpeta de pisadores:"), + ("cfg.pisador_every", "Tocar pisador cada N temas:"), + ("cfg.pisador_excl", "Carpetas que no se pisan:"), + ("cfg.nacionales", "Carpetas Nacionales:"), + ("cfg.intercultural", "Carpetas Intercultural:"), + ("cfg.silence", "Silencio máximo (seg, 0=desact.):"), + ("cfg.upvol", "Volumen principal (0–100):"), + ("cfg.downvol", "Volumen duck (0–100):"), + ("cfg.client_port", "Puerto servidor remoto (gr-client):"), + ("cfg.client_token", "Token de acceso (gr-client):"), + ("cfg.client_token_ph", "(vacío = sin autenticación)"), + ("cfg.relay", "Relay internet:"), + ("cfg.relay_id", "ID de relay (8 dígitos):"), + ("cfg.no_repeat", "No repetir temas de los últimos (días):"), + ("cfg.folders_n", "{n} carpeta(s)"), + ("cfg.pick_pisador", "Elegir carpeta de pisadores"), + // Preset + ("preset.label", "PRESET"), + ("preset.save_dialog", "Guardar preset"), + ("preset.name_label", "Nombre del preset:"), + // Idioma + ("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 G Radio Player."), + ("skin.label", "Skin (apariencia):"), + ("skin.default", "Predeterminado"), + ("skin.restart_title", "Reiniciar para aplicar"), + ("skin.restart_body", "El skin se aplicará la próxima vez que se abra cada ventana."), + ("players.label", "Players (paneles visibles):"), + ("players.3", "3 — Deck A + Deck B + comercial"), + ("players.2", "2 — un deck fusionado + comercial"), + ("players.1", "1 — un solo panel (deck o comercial)"), + ("players.restart_title", "Reiniciar para aplicar"), + ("players.restart_body", "Los paneles de reproducción se reorganizarán la próxima vez que se inicie G Radio Player."), + // Diálogo URL de streaming + ("dialogo.url.title", "Agregar URL de Streaming"), + ("dialogo.url.label", "URL del streaming:"), + ("dialogo.url.history", "Historial:"), + ("dialogo.url.use", "Usar"), + ("dialogo.url.delete_tip", "Eliminar del historial"), + ("dialogo.url.duration", "Duración (segundos):"), + ("dialogo.url.duration_tip", "0 = continuo / sin límite de tiempo"), + ("dialogo.url.continuous_hint", "(0 = continuo)"), + ("dialogo.url.add", "Agregar"), + // Días de la semana + ("dia.lunes", "Lunes"), + ("dia.martes", "Martes"), + ("dia.miercoles", "Miércoles"), + ("dia.jueves", "Jueves"), + ("dia.viernes", "Viernes"), + ("dia.sabado", "Sábado"), + ("dia.domingo", "Domingo"), + // Árbol de archivos + ("tree.home", "Inicio"), + ("tree.system", "Sistema"), + // Ventana de Parrilla Musical + ("parr.win_title", "Parrilla Específica - G Radio"), + ("parr.titulo", "ELABORACIÓN DE LA PARRILLA MUSICAL ESPECÍFICA POR DÍA Y HORAS"), + ("parr.pisadores_hora", "Pisadores esta hora:"), + ("parr.pisadores_ph", "(usa carpeta general)"), + ("parr.pis_browse_tip", "Elegir carpeta de pisadores para esta hora"), + ("parr.fuentes_audio", "FUENTES DE AUDIO"), + ("parr.col_nombre", "Nombre"), + ("parr.btn_marca_hora", "Insertar marca de Hora"), + ("parr.subir_item", "Subir ítem"), + ("parr.bajar_item", "Bajar ítem"), + ("parr.eliminar_item", "Eliminar ítem"), + ("parr.parrilla_musical", "PARRILLA MUSICAL"), + ("parr.btn_leer", "🔍 Leer"), + ("parr.btn_leer_tip", "Cargar parrilla del día y hora seleccionados"), + ("parr.btn_grabar", "⏺ Grabar"), + ("parr.btn_grabar_tip", "Guardar parrilla en el día y hora seleccionados"), + ("parr.frame_horas", "Horas"), + ("parr.frame_dia", "Día"), + ("parr.tip_hora", "Clic: ver hora {h} | Arrastrar: copiar parrilla"), + ("parr.tip_dia", "Clic: seleccionar {d} | Arrastrar: copiar parrilla"), + // Botón "Abrir" en file chooser + ("btn.open", "Abrir"), + // Botonera + ("boto.menu_asignar", "🎵 Asignar audio..."), + ("boto.menu_quitar", "🗑 Quitar audio"), + ("boto.sel_audio", "Seleccionar audio"), + ("boto.filtro_audio", "Archivos de audio"), + ("boto.filtro_todos", "Todos los archivos"), + // Visor de Pautaje + ("visor.win_title", "GR Visor de Pautaje"), + ("visor.header", "Actualizar Pautaje"), + ("visor.btn_refresh", "↻ Actualizar"), + ("visor.pautaje_dia", "Pautaje del día"), + ("visor.col_nombre", "Comercial/Evento"), + ("visor.col_tiempo", "Tiempo"), + ("visor.col_dias", "Días"), + ("visor.col_inicio", "Inicio"), + ("visor.col_fin", "Fin"), + ("visor.parrilla_header", "Parrilla musical — Hora actual: {h}:00 | Siguiente: {hs}:00"), + ("visor.hora_actual", "HORA ACTUAL"), + ("visor.hora_siguiente", "HORA SIGUIENTE"), + ("visor.sin_programacion", "(sin programación)"), + ("visor.aleatorio", "aleatorio"), + ("visor.lista", "lista"), + // Buscador de audio + ("buscador.win_title", "G Radio — Buscador"), + ("buscador.btn_search_tip", "Buscar"), + ("buscador.entry_ph", "Buscar tema…"), + ("buscador.btn_clear", "Limpiar"), + ("buscador.btn_index", "⟳ Índice"), + ("buscador.btn_index_tip", "Actualizar índice de locate (updatedb)"), + ("buscador.col_tema", "Tema Encontrado"), + ("buscador.col_tiempo", "Tiempo"), + ("buscador.col_ruta", "Ruta"), + ("buscador.status_ready", "Listo. Ingresa un término y presiona Enter o 🔍"), + ("buscador.status_listo", "Listo."), + ("buscador.status_buscando", "Buscando \"{q}\"…"), + ("buscador.status_resultados", "{n} resultado(s) para \"{q}\" — doble clic para agregar a la lista, arrastra para posicionar"), + ("buscador.status_indexando", "Actualizando índice locate…"), + ("buscador.status_index_ok", "Índice actualizado correctamente."), + ("buscador.status_index_err", "Error al actualizar índice."), + ("buscador.btn_pause", "⏸ Pausa"), + ("buscador.btn_resume", "▶ Reanudar"), + ("buscador.btn_stop", "■ Stop"), + ("buscador.btn_play_now_tip", "▶ Reproducir ahora (crossfade al deck libre)"), + ("buscador.btn_first", "⤒ Insertar al inicio de la lista"), + ("buscador.btn_last", "⤓ Agregar al final de la lista"), + ("buscador.menu_cue", "🎧 Preescuchar (CUE)"), + ("buscador.menu_add", "➕ Agregar a la lista"), + // Reportes + ("rep.win_title", "G Radio — Reportería de Audios Emitidos"), + ("rep.title", "G Radio — Reporting Tool"), + ("rep.subtitle", "Audios Emitidos — Reporte de Transmisión"), + ("rep.categoria", "CATEGORÍA"), + ("rep.cat_com", "Comerciales"), + ("rep.cat_par", "Parrilla"), + ("rep.cat_ev", "Eventos"), + ("rep.rango_fechas", "RANGO DE FECHAS"), + ("rep.desde", "Desde:"), + ("rep.hasta", "Hasta:"), + ("rep.btn_cargar", "🔍 Cargar datos"), + ("rep.buscar_audio", "BUSCAR AUDIO"), + ("rep.buscar_ph", "Ej: casa baca (insensible a mayúsculas)"), + ("rep.buscar_hint", "Vacío = mostrar todo"), + ("rep.col_fecha", "FECHA"), + ("rep.col_total", "TOTAL"), + ("rep.col_nacional", "NACIONAL"), + ("rep.col_intercult", "INTERCULTURAL"), + ("rep.col_archivo", "ARCHIVO"), + ("rep.col_emisiones", "EMISIONES"), + ("rep.col_dur_total", "DUR. TOTAL"), + ("rep.btn_pdf", "📄 Generar PDF"), + ("rep.btn_csv", "📊 Exportar CSV"), + ("rep.status_inicio", "Selecciona categoría y rango de fechas, luego haz clic en 'Cargar datos'."), + ("rep.status_vacio", "No se encontraron registros para el período seleccionado."), + ("rep.status_resumen", "✓ {titulos} títulos únicos · {emisiones} emisiones totales · {desde} al {hasta}"), + ("rep.status_pdf_ok", "✓ PDF generado: {path}"), + ("rep.status_pdf_err", "✗ Error al generar PDF: {err}"), + ("rep.status_csv_ok", "✓ CSV exportado: {path}"), + ("rep.status_csv_err", "✗ Error al exportar CSV: {err}"), + // Editor de playlists (.gradio) + ("pl.win_title", "Creación de Playlist G Radio"), + ("pl.tip_del", "Eliminar seleccionado"), + ("pl.tip_up", "Subir"), + ("pl.tip_down", "Bajar"), + ("pl.tip_play_cue", "Pre-escuchar"), + ("pl.tip_stop_cue", "Detener pre-escucha"), + ("pl.tip_clock", "Calcular tiempos"), + ("pl.col_tema", "Tema Musical"), + ("pl.col_tiempo", "Tiempo"), + ("pl.col_ruta", "Ruta"), + ("pl.btn_clear", "🔴 Borrar Lista"), + ("pl.btn_load", "Cargar Lista"), + ("pl.btn_save", "Guardar Lista"), + ("pl.load_title", "Cargar lista .gradio"), + ("pl.save_title", "Guardar lista .gradio"), + ("pl.filter", "Listas G Radio (*.gradio)"), + ("pl.default_name", "nueva_lista.gradio"), + // Árbol de archivos (panel_arbol.rs) + ("arbol.pautar_carpeta", "Pautar carpeta completa (aleatoria)"), + // GR Pautaje — ventana principal + ("paut.tip_acumulado", "Tiempo total acumulado en la hora"), + ("paut.tip_corte", "Tiempo de este corte"), + ("paut.col_comercial", "Comercial"), + ("paut.col_dias", "LMXJVSD"), + ("paut.col_inicio", "Inicio"), + ("paut.col_fin", "Fin"), + ("paut.col_ruta", "Ruta"), + ("paut.fecha_inicio", "Inicio"), + ("paut.fecha_fin", "Fin"), + ("paut.tip_limpiar", "Eliminar comerciales caducados"), + ("paut.confirm_caducados", "¿Desea eliminar todos los comerciales caducados?"), + ("paut.info_caducados", "Se eliminaron {n} entradas caducadas."), + ("paut.tipo_comerciales", "Comerciales"), + ("paut.tipo_eventos", "Eventos"), + ("paut.tipo_eventos_espera","Eventos en Espera"), + ("paut.tip_subir", "Mover hacia arriba"), + ("paut.tip_bajar", "Mover hacia abajo"), + ("paut.tip_eliminar", "Eliminar seleccionado del pautaje"), + ("paut.tip_play_cue", "Reproducir en segunda tarjeta de audio"), + ("paut.tip_url", "Agregar URL de streaming"), + ("paut.tip_hora", "Insertar señal de hora en el pautaje"), + ("paut.tip_linea", "Entrada de línea"), + // Diálogos del player principal + ("dlg.ruta_archivo", "Ruta del archivo"), + ("dlg.copiar_ruta", "📋 Copiar ruta"), + ("dlg.ins_stream_title", "📡 Insertar Streaming"), + ("dlg.url_lbl", "URL:"), + ("dlg.duracion_s", "Duración (s):"), + ("dlg.btn_insertar", "📡 Insertar"), + ("dlg.confirm_interrumpir","Hay comerciales o eventos reproduciéndose.\n¿Interrumpirlos y reproducir el audio en el deck?"), + ("dlg.filter_zip", "Archivos ZIP"), + ("dlg.recovery_title", "Recuperación completada"), + ("dlg.recovery_body", "✅ Configuración restaurada.\n{n} archivos recuperados.\n\nReinicia el reproductor para aplicar los cambios."), + // Herramientas y paneles del player + ("tool.auto_refill", "Automático — pausa el relleno automático de la parrilla\nVerde=automático · Rojo=pausado"), + ("tool.vaciar_playlist", "Vaciar cola de reproducción"), + ("playlist.titulo", "🎵 Cola de reproducción [doble-click=reproducir · arrastrar=reordenar · soltar archivo=insertar]"), + ("playlist.total", "Total"), + ("panel.comerciales_tandas","📢 Comerciales — próximas tandas"), + ("panel.eventos_espera", "⏳ Eventos en espera"), + // Menú contextual de la cola + ("ctx.cue", "🎧 Preescuchar (CUE)"), + ("ctx.ins_hora", "🕐 Insertar Hora aquí"), + ("ctx.ins_stream", "📡 Insertar Streaming aquí"), + ("ctx.load_list", "📋 Cargar lista .gradio aquí"), + ("ctx.regen", "🔄 Regenerar lista"), + ("ctx.del_cola", "🗑 Eliminar de la cola"), + // Ventana flotante de CUE + ("cue.win_title", "🎧 CUE: {title}"), + ("cue.btn_pause", "⏸ Pausa"), + ("cue.btn_resume", "▶ Reanudar"), + ("cue.btn_stop", "■ Stop"), + ("tip.no_repeat", "Días hacia atrás que se consultan para evitar repetir un tema. 1 = solo hoy, 3 = tres días (recomendado)."), + ("panel.eventos", "📅 Eventos"), + ("dlg.error", "Error"), + ("dlg.backup_title", "Respaldo completado"), + ("dlg.backup_body", "✅ Respaldo creado exitosamente.\n{n} archivos guardados en:\n{path}"), + ("dlg.backup_err", "❌ Error al crear respaldo:\n{err}"), + ("dlg.sel_backup", "Seleccionar respaldo GR"), + ("dlg.btn_restore", "Restaurar"), + ("dlg.recovery_err", "❌ Error al recuperar:\n{err}"), + ("dlg.token_title", "Token obligatorio"), + ("dlg.token_body", "⚠ El relay de internet requiere un token de acceso.\nSin token, cualquiera que adivine el ID puede controlar el reproductor.\n\nIngresa un token en el campo 'Token de acceso (gr-client)' antes de activar el relay."), + // Fundido + ("cfg.fundido_excl", "Carpetas que no se funden:"), + ("tip.fundido_excl", "Los archivos en estas carpetas se reproducen con arranque directo (sin crossfade), para no cortar el inicio ni el final — útil para locuciones/IDs."), + // IA + ("cfg.ia", "Inteligencia Artificial:"), + ("tip.ia", "Habilita el asistente de IA integrado (OpenCode con modelos abiertos gratuitos)."), + ("tip.ia_btn", "Abrir asistente IA para G-Radio"), + // Locutor Automático + ("cfg.locutor", "Locutor Automático:"), + ("tip.locutor", "Habilita la generación de locuciones por IA a cargo de agentes externos (experimental). El estado se guarda en tmp/locutor_auto para que esos agentes lo lean."), + ("btn.ia", "IA"), + ("dlg.ia_warn_title", "Habilitar Inteligencia Artificial"), + ("dlg.ia_warn_body", "Esta función usa OpenCode con modelos de IA abiertos y gratuitos.\n\nEl asistente puede leer y modificar archivos de programación de la radio.\n\n⚠ El uso de IA puede generar resultados inesperados. Se recomienda\nrevisar los cambios antes de aplicarlos en transmisión.\n\n¿Aceptas estas condiciones y deseas habilitar la IA?"), + ("dlg.ia_no_bin", "⚠ No se encontró opencode instalado.\n\nInstálalo desde: https://opencode.ai\n\nO coloca el binario en:\n ~/.local/bin/opencode"), + ("dlg.ia_accept", "✅ Aceptar y habilitar"), + ("dlg.locutor_warn_title", "Habilitar Locutor Automático"), + ("dlg.locutor_warn_body", "El Locutor Automático genera locuciones de radio (voz IA) a cargo de un agente de IA externo (Claude/OpenCode), no de este programa.\n\nEsta función solo activa la bandera que ese agente lee para decidir si debe generar audio; sin un agente configurado y corriendo, no ocurre nada.\n\n⚠ El agente puede leer y escribir archivos de programación de la radio\n(parrilla, comerciales, eventos) para insertar las locuciones generadas.\n\n¿Aceptas estas condiciones y deseas habilitar el Locutor Automático?"), + ("dlg.locutor_accept", "✅ Aceptar y habilitar"), + ("preset.default_name", "Mi preset"), +]; + +const EN: Dict = &[ + ("win.player", "G Radio Player"), + ("win.pautaje", "GR Schedule"), + ("win.processor", "GR DSP Processor"), + ("win.config", "GR Settings"), + ("btn.ok", "OK"), + ("btn.cancel", "Cancel"), + ("btn.save", "Save"), + ("btn.close", "Close"), + ("btn.interrupt", "Interrupt"), + ("btn.generate", "Generate"), + ("btn.all", "All"), + ("btn.choose_folder", "Choose folder"), + ("btn.edit_list", "✏ Edit list…"), + ("btn.select", "Select"), + ("btn.operador", "🎛 Operator"), + ("now.stopped_end", "⏹ Stopped at end of track"), + ("now.app_name", "G Radio Player"), + ("now.no_track", "No track"), + ("now.stopped_after", "Stopped: Stop after"), + ("now.no_ad", "No ad"), + ("deck.a", "🎵 Deck A (odd tracks)"), + ("deck.b", "🎵 Deck B (even tracks)"), + ("tool.start", "Start playback"), + ("tool.stop_general", "Stop All — stops schedule, commercials and events"), + ("tool.next", "Next track"), + ("tool.hora", "Play hour"), + ("tool.pisador", "Sweeper"), + ("tool.duck", "Duck music"), + ("tool.buscador", "Audio search"), + ("tool.pautaje", "Schedule"), + ("tool.parrilla", "Grid"), + ("tool.playlist", ".gradio playlist editor"), + ("tool.botonera", "Hot keys"), + ("tool.config", "Settings"), + ("tool.visor", "Viewer"), + ("tool.record", "Record"), + ("tool.reportes", "Aired audio reports"), + ("cola.titulo", "⬛ Active queue"), + ("cola.vaciar", "🗑 Clear"), + ("cola.no_tandas", "No commercial breaks scheduled in the coming hours"), + ("vol.fmt", "Vol: {up}% | Duck: {dn}% | Fade: {mix}s"), + ("tip.empty_queue", "Clear all queued commercials"), + ("tip.vu_meter", "VU Meter"), + ("tip.dsp_on", "DSP processor active — click to disable"), + ("tip.dsp_off", "DSP processor inactive — click to enable"), + ("tip.upvol", "Main audio volume level (0–100)."), + ("tip.downvol", "Audio level during ducking (commercials playing) (0–100)."), + ("tip.client_port", "TCP port gr-client will connect to (default: 7777)."), + ("tip.client_token", "Token remote clients must send to connect. Empty = no auth."), + ("tip.gen_id", "Generate a random 8-digit ID."), + ("tip.silence", "Seconds of sustained silence before skipping to the next track. 0 = disabled."), + ("tip.relay", "Enable the connection to the relay server for remote access over the internet."), + ("tip.relay_id", "8-digit ID that identifies this server on the relay. Share it with gr-client to connect over the internet."), + ("cfg.audio_main", "Main audio device:"), + ("cfg.audio_cue", "CUE audio device:"), + ("cfg.medio_name", "Station name:"), + ("cfg.crossfade", "Crossfade (sec):"), + ("cfg.pisador_on", "Sweeper over tracks:"), + ("cfg.pisador_dir", "Sweepers folder:"), + ("cfg.pisador_every", "Play sweeper every N tracks:"), + ("cfg.pisador_excl", "Folders without sweepers:"), + ("cfg.nacionales", "National folders:"), + ("cfg.intercultural", "Intercultural folders:"), + ("cfg.silence", "Max silence (sec, 0=off):"), + ("cfg.upvol", "Main volume (0–100):"), + ("cfg.downvol", "Duck volume (0–100):"), + ("cfg.client_port", "Remote server port (gr-client):"), + ("cfg.client_token", "Access token (gr-client):"), + ("cfg.client_token_ph", "(empty = no authentication)"), + ("cfg.relay", "Internet relay:"), + ("cfg.relay_id", "Relay ID (8 digits):"), + ("cfg.no_repeat", "Do not repeat tracks from the last (days):"), + ("cfg.folders_n", "{n} folder(s)"), + ("cfg.pick_pisador", "Choose sweepers folder"), + ("preset.label", "PRESET"), + ("preset.save_dialog", "Save preset"), + ("preset.name_label", "Preset name:"), + ("lang.label", "Language:"), + ("lang.auto", "Automatic"), + ("lang.restart_title", "Restart to apply"), + ("lang.restart_body", "The language change will take effect next time G Radio Player starts."), + ("skin.label", "Skin (appearance):"), + ("skin.default", "Default"), + ("skin.restart_title", "Restart to apply"), + ("skin.restart_body", "The skin will take effect next time each window is opened."), + ("players.label", "Players (visible panels):"), + ("players.3", "3 — Deck A + Deck B + commercial"), + ("players.2", "2 — one merged deck + commercial"), + ("players.1", "1 — single panel (deck or commercial)"), + ("players.restart_title", "Restart to apply"), + ("players.restart_body", "The playback panels will rearrange next time G Radio Player starts."), + ("dialogo.url.title", "Add streaming URL"), + ("dialogo.url.label", "Streaming URL:"), + ("dialogo.url.history", "History:"), + ("dialogo.url.use", "Use"), + ("dialogo.url.delete_tip", "Remove from history"), + ("dialogo.url.duration", "Duration (seconds):"), + ("dialogo.url.duration_tip", "0 = continuous / no time limit"), + ("dialogo.url.continuous_hint", "(0 = continuous)"), + ("dialogo.url.add", "Add"), + ("dia.lunes", "Monday"), + ("dia.martes", "Tuesday"), + ("dia.miercoles", "Wednesday"), + ("dia.jueves", "Thursday"), + ("dia.viernes", "Friday"), + ("dia.sabado", "Saturday"), + ("dia.domingo", "Sunday"), + ("tree.home", "Home"), + ("tree.system", "System"), + ("parr.win_title", "Specific Schedule - G Radio"), + ("parr.titulo", "BUILD THE SPECIFIC MUSIC SCHEDULE BY DAY AND HOUR"), + ("parr.pisadores_hora", "Sweepers this hour:"), + ("parr.pisadores_ph", "(use general folder)"), + ("parr.pis_browse_tip", "Choose sweepers folder for this hour"), + ("parr.fuentes_audio", "AUDIO SOURCES"), + ("parr.col_nombre", "Name"), + ("parr.btn_marca_hora", "Insert hour marker"), + ("parr.subir_item", "Move item up"), + ("parr.bajar_item", "Move item down"), + ("parr.eliminar_item", "Delete item"), + ("parr.parrilla_musical", "MUSIC SCHEDULE"), + ("parr.btn_leer", "🔍 Load"), + ("parr.btn_leer_tip", "Load schedule for the selected day and hour"), + ("parr.btn_grabar", "⏺ Save"), + ("parr.btn_grabar_tip", "Save schedule to the selected day and hour"), + ("parr.frame_horas", "Hours"), + ("parr.frame_dia", "Day"), + ("parr.tip_hora", "Click: view hour {h} | Drag: copy schedule"), + ("parr.tip_dia", "Click: select {d} | Drag: copy schedule"), + ("btn.open", "Open"), + ("boto.menu_asignar", "🎵 Assign audio..."), + ("boto.menu_quitar", "🗑 Remove audio"), + ("boto.sel_audio", "Select audio"), + ("boto.filtro_audio", "Audio files"), + ("boto.filtro_todos", "All files"), + ("visor.win_title", "GR Schedule Viewer"), + ("visor.header", "Refresh schedule"), + ("visor.btn_refresh", "↻ Refresh"), + ("visor.pautaje_dia", "Today's schedule"), + ("visor.col_nombre", "Commercial/Event"), + ("visor.col_tiempo", "Length"), + ("visor.col_dias", "Days"), + ("visor.col_inicio", "Start"), + ("visor.col_fin", "End"), + ("visor.parrilla_header", "Music schedule — Current hour: {h}:00 | Next: {hs}:00"), + ("visor.hora_actual", "CURRENT HOUR"), + ("visor.hora_siguiente", "NEXT HOUR"), + ("visor.sin_programacion", "(nothing scheduled)"), + ("visor.aleatorio", "random"), + ("visor.lista", "list"), + ("buscador.win_title", "G Radio — Search"), + ("buscador.btn_search_tip", "Search"), + ("buscador.entry_ph", "Search track…"), + ("buscador.btn_clear", "Clear"), + ("buscador.btn_index", "⟳ Index"), + ("buscador.btn_index_tip", "Update locate index (updatedb)"), + ("buscador.col_tema", "Track found"), + ("buscador.col_tiempo", "Length"), + ("buscador.col_ruta", "Path"), + ("buscador.status_ready", "Ready. Type a term and press Enter or 🔍"), + ("buscador.status_listo", "Ready."), + ("buscador.status_buscando", "Searching \"{q}\"…"), + ("buscador.status_resultados", "{n} result(s) for \"{q}\" — double-click to append, drag to position"), + ("buscador.status_indexando", "Updating locate index…"), + ("buscador.status_index_ok", "Index updated successfully."), + ("buscador.status_index_err", "Error updating index."), + ("buscador.btn_pause", "⏸ Pause"), + ("buscador.btn_resume", "▶ Resume"), + ("buscador.btn_stop", "■ Stop"), + ("buscador.btn_play_now_tip", "▶ Play now (crossfade to the free deck)"), + ("buscador.btn_first", "⤒ Insert at top of the list"), + ("buscador.btn_last", "⤓ Append to the end of the list"), + ("buscador.menu_cue", "🎧 Pre-listen (CUE)"), + ("buscador.menu_add", "➕ Add to the list"), + ("rep.win_title", "G Radio — Aired Audio Reports"), + ("rep.title", "G Radio — Reporting Tool"), + ("rep.subtitle", "Aired audio — Broadcast report"), + ("rep.categoria", "CATEGORY"), + ("rep.cat_com", "Commercials"), + ("rep.cat_par", "Schedule"), + ("rep.cat_ev", "Events"), + ("rep.rango_fechas", "DATE RANGE"), + ("rep.desde", "From:"), + ("rep.hasta", "To:"), + ("rep.btn_cargar", "🔍 Load data"), + ("rep.buscar_audio", "SEARCH AUDIO"), + ("rep.buscar_ph", "E.g.: casa baca (case-insensitive)"), + ("rep.buscar_hint", "Empty = show all"), + ("rep.col_fecha", "DATE"), + ("rep.col_total", "TOTAL"), + ("rep.col_nacional", "NATIONAL"), + ("rep.col_intercult", "INTERCULTURAL"), + ("rep.col_archivo", "FILE"), + ("rep.col_emisiones", "AIRINGS"), + ("rep.col_dur_total", "TOTAL DUR."), + ("rep.btn_pdf", "📄 Generate PDF"), + ("rep.btn_csv", "📊 Export CSV"), + ("rep.status_inicio", "Select category and date range, then click 'Load data'."), + ("rep.status_vacio", "No records were found for the selected period."), + ("rep.status_resumen", "✓ {titulos} unique titles · {emisiones} total airings · {desde} to {hasta}"), + ("rep.status_pdf_ok", "✓ PDF generated: {path}"), + ("rep.status_pdf_err", "✗ Error generating PDF: {err}"), + ("rep.status_csv_ok", "✓ CSV exported: {path}"), + ("rep.status_csv_err", "✗ Error exporting CSV: {err}"), + ("pl.win_title", "G Radio playlist editor"), + ("pl.tip_del", "Delete selected"), + ("pl.tip_up", "Move up"), + ("pl.tip_down", "Move down"), + ("pl.tip_play_cue", "Pre-listen"), + ("pl.tip_stop_cue", "Stop pre-listen"), + ("pl.tip_clock", "Compute lengths"), + ("pl.col_tema", "Track"), + ("pl.col_tiempo", "Length"), + ("pl.col_ruta", "Path"), + ("pl.btn_clear", "🔴 Clear list"), + ("pl.btn_load", "Load list"), + ("pl.btn_save", "Save list"), + ("pl.load_title", "Load .gradio list"), + ("pl.save_title", "Save .gradio list"), + ("pl.filter", "G Radio lists (*.gradio)"), + ("pl.default_name", "new_list.gradio"), + ("arbol.pautar_carpeta", "Schedule the whole folder (random)"), + ("paut.tip_acumulado", "Total time accumulated in this hour"), + ("paut.tip_corte", "Time of this slot"), + ("paut.col_comercial", "Commercial"), + ("paut.col_dias", "MTWTFSS"), + ("paut.col_inicio", "Start"), + ("paut.col_fin", "End"), + ("paut.col_ruta", "Path"), + ("paut.fecha_inicio", "Start"), + ("paut.fecha_fin", "End"), + ("paut.tip_limpiar", "Delete expired commercials"), + ("paut.confirm_caducados", "Do you want to delete all expired commercials?"), + ("paut.info_caducados", "{n} expired entries were removed."), + ("paut.tipo_comerciales", "Commercials"), + ("paut.tipo_eventos", "Events"), + ("paut.tipo_eventos_espera","Events on hold"), + ("paut.tip_subir", "Move up"), + ("paut.tip_bajar", "Move down"), + ("paut.tip_eliminar", "Delete selected entry"), + ("paut.tip_play_cue", "Play on the second audio card"), + ("paut.tip_url", "Add streaming URL"), + ("paut.tip_hora", "Insert hour cue into the schedule"), + ("paut.tip_linea", "Line input"), + ("dlg.ruta_archivo", "File path"), + ("dlg.copiar_ruta", "📋 Copy path"), + ("dlg.ins_stream_title", "📡 Insert streaming"), + ("dlg.url_lbl", "URL:"), + ("dlg.duracion_s", "Duration (s):"), + ("dlg.btn_insertar", "📡 Insert"), + ("dlg.confirm_interrumpir","Commercials or events are playing.\nInterrupt them and play the audio on the deck?"), + ("dlg.filter_zip", "ZIP files"), + ("dlg.recovery_title", "Recovery completed"), + ("dlg.recovery_body", "✅ Settings restored.\n{n} files recovered.\n\nRestart the player to apply the changes."), + ("tool.auto_refill", "Auto — pauses the schedule's automatic refill\nGreen=auto · Red=paused"), + ("tool.vaciar_playlist", "Clear playback queue"), + ("playlist.titulo", "🎵 Playback queue [double-click=play · drag=reorder · drop file=insert]"), + ("playlist.total", "Total"), + ("panel.comerciales_tandas","📢 Commercials — upcoming breaks"), + ("panel.eventos_espera", "⏳ Events on hold"), + ("ctx.cue", "🎧 Pre-listen (CUE)"), + ("ctx.ins_hora", "🕐 Insert hour here"), + ("ctx.ins_stream", "📡 Insert streaming here"), + ("ctx.load_list", "📋 Load .gradio list here"), + ("ctx.regen", "🔄 Regenerate list"), + ("ctx.del_cola", "🗑 Remove from queue"), + ("cue.win_title", "🎧 CUE: {title}"), + ("cue.btn_pause", "⏸ Pause"), + ("cue.btn_resume", "▶ Resume"), + ("cue.btn_stop", "■ Stop"), + ("tip.no_repeat", "Days back to look up to avoid repeating a track. 1 = today only, 3 = three days (recommended)."), + ("panel.eventos", "📅 Events"), + ("dlg.error", "Error"), + ("dlg.backup_title", "Backup completed"), + ("dlg.backup_body", "✅ Backup created successfully.\n{n} files saved to:\n{path}"), + ("dlg.backup_err", "❌ Backup failed:\n{err}"), + ("dlg.sel_backup", "Select GR backup"), + ("dlg.btn_restore", "Restore"), + ("dlg.recovery_err", "❌ Restore failed:\n{err}"), + ("dlg.token_title", "Token required"), + ("dlg.token_body", "⚠ The internet relay requires an access token.\nWithout one, anyone who guesses the ID can control the player.\n\nEnter a token in the 'Access token (gr-client)' field before enabling the relay."), + // Fade + ("cfg.fundido_excl", "Folders without crossfade:"), + ("tip.fundido_excl", "Files in these folders play with a direct start (no crossfade), so the start/end isn't cut off — useful for voice IDs/announcements."), + // AI + ("cfg.ia", "Artificial Intelligence:"), + ("tip.ia", "Enable the built-in AI assistant (OpenCode with free open models)."), + ("tip.ia_btn", "Open AI assistant for G-Radio"), + // Automatic Announcer + ("cfg.locutor", "Automatic Announcer:"), + ("tip.locutor", "Enables AI-generated announcements handled by external agents (experimental). The state is saved to tmp/locutor_auto for those agents to read."), + ("btn.ia", "AI"), + ("dlg.ia_warn_title", "Enable Artificial Intelligence"), + ("dlg.ia_warn_body", "This feature uses OpenCode with free open-source AI models.\n\nThe assistant can read and modify radio scheduling files.\n\n⚠ AI may produce unexpected results. Review changes before\napplying them during broadcast.\n\nDo you accept these conditions and want to enable AI?"), + ("dlg.ia_no_bin", "⚠ opencode not found.\n\nInstall it from: https://opencode.ai\n\nOr place the binary at:\n ~/.local/bin/opencode"), + ("dlg.ia_accept", "✅ Accept and enable"), + ("dlg.locutor_warn_title", "Enable Automatic Announcer"), + ("dlg.locutor_warn_body", "The Automatic Announcer generates radio announcements (AI voice) through an external AI agent (Claude/OpenCode), not this program.\n\nThis feature only turns on the flag that agent reads to decide whether to generate audio; without a configured agent running, nothing happens.\n\n⚠ The agent may read and write radio scheduling files\n(playlist, ads, events) to insert the generated announcements.\n\nDo you accept these conditions and want to enable the Automatic Announcer?"), + ("dlg.locutor_accept", "✅ Accept and enable"), + ("preset.default_name", "My preset"), +]; + +const PT: Dict = &[ + ("win.player", "G Radio Player"), + ("win.pautaje", "GR Grade"), + ("win.processor", "GR Processador DSP"), + ("win.config", "Configurações GR"), + ("btn.ok", "OK"), + ("btn.cancel", "Cancelar"), + ("btn.save", "Salvar"), + ("btn.close", "Fechar"), + ("btn.interrupt", "Interromper"), + ("btn.generate", "Gerar"), + ("btn.all", "Todos"), + ("btn.choose_folder", "Escolher pasta"), + ("btn.edit_list", "✏ Editar lista…"), + ("btn.select", "Selecionar"), + ("btn.operador", "🎛 Operador"), + ("now.stopped_end", "⏹ Parado no fim da faixa"), + ("now.app_name", "G Radio Player"), + ("now.no_track", "Sem faixa"), + ("now.stopped_after", "Parado: Parar depois"), + ("now.no_ad", "Sem comercial"), + ("deck.a", "🎵 Deck A (faixas ímpares)"), + ("deck.b", "🎵 Deck B (faixas pares)"), + ("tool.start", "Iniciar reprodução"), + ("tool.stop_general", "Parar Tudo — para grade, comerciais e eventos"), + ("tool.next", "Próxima faixa"), + ("tool.hora", "Tocar hora"), + ("tool.pisador", "Vinheta"), + ("tool.duck", "Abaixar música (duck)"), + ("tool.buscador", "Busca de áudio"), + ("tool.pautaje", "Programação"), + ("tool.parrilla", "Grade"), + ("tool.playlist", "Editor de playlists .gradio"), + ("tool.botonera", "Botoeira"), + ("tool.config", "Configurações"), + ("tool.visor", "Visor"), + ("tool.record", "Gravar"), + ("tool.reportes", "Relatórios de áudios reproduzidos"), + ("cola.titulo", "⬛ Fila ativa"), + ("cola.vaciar", "🗑 Limpar"), + ("cola.no_tandas", "Sem blocos comerciais nas próximas horas"), + ("vol.fmt", "Vol: {up}% | Duck: {dn}% | Fade: {mix}s"), + ("tip.empty_queue", "Limpar todos os comerciais na fila"), + ("tip.vu_meter", "VU Meter"), + ("tip.dsp_on", "Processador DSP ativo — clique para desativar"), + ("tip.dsp_off", "Processador DSP inativo — clique para ativar"), + ("tip.upvol", "Nível de volume do áudio principal (0–100)."), + ("tip.downvol", "Nível de volume durante a atenuação (duck) por comerciais (0–100)."), + ("tip.client_port", "Porta TCP em que gr-client conectará a este servidor (padrão: 7777)."), + ("tip.client_token", "Token que clientes remotos devem enviar ao conectar. Vazio = sem auth."), + ("tip.gen_id", "Gera um ID aleatório de 8 dígitos."), + ("tip.silence", "Segundos de silêncio sustentado antes de avançar para a próxima faixa. 0 = desativado."), + ("tip.relay", "Ativa a conexão com o servidor de relay para acesso remoto via internet."), + ("tip.relay_id", "ID de 8 dígitos que identifica este servidor no relay. Compartilhe-o com gr-client para conectar pela internet."), + ("cfg.audio_main", "Placa de áudio principal:"), + ("cfg.audio_cue", "Placa de áudio CUE:"), + ("cfg.medio_name", "Nome da rádio:"), + ("cfg.crossfade", "Tempo de fade (seg):"), + ("cfg.pisador_on", "Vinheta sobre faixas:"), + ("cfg.pisador_dir", "Pasta de vinhetas:"), + ("cfg.pisador_every", "Tocar vinheta a cada N faixas:"), + ("cfg.pisador_excl", "Pastas sem vinhetas:"), + ("cfg.nacionales", "Pastas nacionais:"), + ("cfg.intercultural", "Pastas interculturais:"), + ("cfg.silence", "Silêncio máximo (seg, 0=desat.):"), + ("cfg.upvol", "Volume principal (0–100):"), + ("cfg.downvol", "Volume duck (0–100):"), + ("cfg.client_port", "Porta servidor remoto (gr-client):"), + ("cfg.client_token", "Token de acesso (gr-client):"), + ("cfg.client_token_ph", "(vazio = sem autenticação)"), + ("cfg.relay", "Relay internet:"), + ("cfg.relay_id", "ID de relay (8 dígitos):"), + ("cfg.no_repeat", "Não repetir faixas dos últimos (dias):"), + ("cfg.folders_n", "{n} pasta(s)"), + ("cfg.pick_pisador", "Escolher pasta de vinhetas"), + ("preset.label", "PRESET"), + ("preset.save_dialog", "Salvar preset"), + ("preset.name_label", "Nome do preset:"), + ("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 G Radio Player."), + ("skin.label", "Skin (aparência):"), + ("skin.default", "Padrão"), + ("skin.restart_title", "Reinicie para aplicar"), + ("skin.restart_body", "O skin será aplicado na próxima vez que cada janela for aberta."), + ("players.label", "Players (painéis visíveis):"), + ("players.3", "3 — Deck A + Deck B + comercial"), + ("players.2", "2 — um deck fundido + comercial"), + ("players.1", "1 — um único painel (deck ou comercial)"), + ("players.restart_title", "Reinicie para aplicar"), + ("players.restart_body", "Os painéis de reprodução serão reorganizados na próxima vez que o G Radio Player for iniciado."), + ("dialogo.url.title", "Adicionar URL de streaming"), + ("dialogo.url.label", "URL do streaming:"), + ("dialogo.url.history", "Histórico:"), + ("dialogo.url.use", "Usar"), + ("dialogo.url.delete_tip", "Remover do histórico"), + ("dialogo.url.duration", "Duração (segundos):"), + ("dialogo.url.duration_tip", "0 = contínuo / sem limite de tempo"), + ("dialogo.url.continuous_hint", "(0 = contínuo)"), + ("dialogo.url.add", "Adicionar"), + ("dia.lunes", "Segunda"), + ("dia.martes", "Terça"), + ("dia.miercoles", "Quarta"), + ("dia.jueves", "Quinta"), + ("dia.viernes", "Sexta"), + ("dia.sabado", "Sábado"), + ("dia.domingo", "Domingo"), + ("tree.home", "Início"), + ("tree.system", "Sistema"), + ("parr.win_title", "Grade Específica - G Radio"), + ("parr.titulo", "ELABORAÇÃO DA GRADE MUSICAL ESPECÍFICA POR DIA E HORAS"), + ("parr.pisadores_hora", "Vinhetas esta hora:"), + ("parr.pisadores_ph", "(usa pasta geral)"), + ("parr.pis_browse_tip", "Escolher pasta de vinhetas para esta hora"), + ("parr.fuentes_audio", "FONTES DE ÁUDIO"), + ("parr.col_nombre", "Nome"), + ("parr.btn_marca_hora", "Inserir marca de Hora"), + ("parr.subir_item", "Subir item"), + ("parr.bajar_item", "Descer item"), + ("parr.eliminar_item", "Excluir item"), + ("parr.parrilla_musical", "GRADE MUSICAL"), + ("parr.btn_leer", "🔍 Ler"), + ("parr.btn_leer_tip", "Carregar grade do dia e hora selecionados"), + ("parr.btn_grabar", "⏺ Gravar"), + ("parr.btn_grabar_tip", "Salvar grade no dia e hora selecionados"), + ("parr.frame_horas", "Horas"), + ("parr.frame_dia", "Dia"), + ("parr.tip_hora", "Clique: ver hora {h} | Arrastar: copiar grade"), + ("parr.tip_dia", "Clique: selecionar {d} | Arrastar: copiar grade"), + ("btn.open", "Abrir"), + ("boto.menu_asignar", "🎵 Atribuir áudio..."), + ("boto.menu_quitar", "🗑 Remover áudio"), + ("boto.sel_audio", "Selecionar áudio"), + ("boto.filtro_audio", "Arquivos de áudio"), + ("boto.filtro_todos", "Todos os arquivos"), + ("visor.win_title", "GR Visualizador de Programação"), + ("visor.header", "Atualizar programação"), + ("visor.btn_refresh", "↻ Atualizar"), + ("visor.pautaje_dia", "Programação do dia"), + ("visor.col_nombre", "Comercial/Evento"), + ("visor.col_tiempo", "Tempo"), + ("visor.col_dias", "Dias"), + ("visor.col_inicio", "Início"), + ("visor.col_fin", "Fim"), + ("visor.parrilla_header", "Grade musical — Hora atual: {h}:00 | Próxima: {hs}:00"), + ("visor.hora_actual", "HORA ATUAL"), + ("visor.hora_siguiente", "PRÓXIMA HORA"), + ("visor.sin_programacion", "(sem programação)"), + ("visor.aleatorio", "aleatório"), + ("visor.lista", "lista"), + ("buscador.win_title", "G Radio — Busca"), + ("buscador.btn_search_tip", "Buscar"), + ("buscador.entry_ph", "Buscar faixa…"), + ("buscador.btn_clear", "Limpar"), + ("buscador.btn_index", "⟳ Índice"), + ("buscador.btn_index_tip", "Atualizar índice do locate (updatedb)"), + ("buscador.col_tema", "Faixa encontrada"), + ("buscador.col_tiempo", "Tempo"), + ("buscador.col_ruta", "Caminho"), + ("buscador.status_ready", "Pronto. Digite um termo e pressione Enter ou 🔍"), + ("buscador.status_listo", "Pronto."), + ("buscador.status_buscando", "Buscando \"{q}\"…"), + ("buscador.status_resultados", "{n} resultado(s) para \"{q}\" — duplo clique para adicionar, arraste para posicionar"), + ("buscador.status_indexando", "Atualizando índice locate…"), + ("buscador.status_index_ok", "Índice atualizado com sucesso."), + ("buscador.status_index_err", "Erro ao atualizar índice."), + ("buscador.btn_pause", "⏸ Pausa"), + ("buscador.btn_resume", "▶ Retomar"), + ("buscador.btn_stop", "■ Stop"), + ("buscador.btn_play_now_tip", "▶ Reproduzir agora (crossfade no deck livre)"), + ("buscador.btn_first", "⤒ Inserir no início da lista"), + ("buscador.btn_last", "⤓ Adicionar ao final da lista"), + ("buscador.menu_cue", "🎧 Pré-escuta (CUE)"), + ("buscador.menu_add", "➕ Adicionar à lista"), + ("rep.win_title", "G Radio — Relatórios de Áudios Reproduzidos"), + ("rep.title", "G Radio — Reporting Tool"), + ("rep.subtitle", "Áudios reproduzidos — Relatório de transmissão"), + ("rep.categoria", "CATEGORIA"), + ("rep.cat_com", "Comerciais"), + ("rep.cat_par", "Grade"), + ("rep.cat_ev", "Eventos"), + ("rep.rango_fechas", "PERÍODO"), + ("rep.desde", "De:"), + ("rep.hasta", "Até:"), + ("rep.btn_cargar", "🔍 Carregar dados"), + ("rep.buscar_audio", "BUSCAR ÁUDIO"), + ("rep.buscar_ph", "Ex.: casa baca (sem distinguir maiúsculas)"), + ("rep.buscar_hint", "Vazio = mostrar tudo"), + ("rep.col_fecha", "DATA"), + ("rep.col_total", "TOTAL"), + ("rep.col_nacional", "NACIONAL"), + ("rep.col_intercult", "INTERCULTURAL"), + ("rep.col_archivo", "ARQUIVO"), + ("rep.col_emisiones", "EXIBIÇÕES"), + ("rep.col_dur_total", "DUR. TOTAL"), + ("rep.btn_pdf", "📄 Gerar PDF"), + ("rep.btn_csv", "📊 Exportar CSV"), + ("rep.status_inicio", "Selecione categoria e período, depois clique em 'Carregar dados'."), + ("rep.status_vacio", "Nenhum registro encontrado no período selecionado."), + ("rep.status_resumen", "✓ {titulos} títulos únicos · {emisiones} exibições totais · {desde} a {hasta}"), + ("rep.status_pdf_ok", "✓ PDF gerado: {path}"), + ("rep.status_pdf_err", "✗ Erro ao gerar PDF: {err}"), + ("rep.status_csv_ok", "✓ CSV exportado: {path}"), + ("rep.status_csv_err", "✗ Erro ao exportar CSV: {err}"), + ("pl.win_title", "Criação de Playlist G Radio"), + ("pl.tip_del", "Excluir selecionado"), + ("pl.tip_up", "Subir"), + ("pl.tip_down", "Descer"), + ("pl.tip_play_cue", "Pré-escutar"), + ("pl.tip_stop_cue", "Parar pré-escuta"), + ("pl.tip_clock", "Calcular tempos"), + ("pl.col_tema", "Faixa"), + ("pl.col_tiempo", "Tempo"), + ("pl.col_ruta", "Caminho"), + ("pl.btn_clear", "🔴 Apagar lista"), + ("pl.btn_load", "Carregar lista"), + ("pl.btn_save", "Salvar lista"), + ("pl.load_title", "Carregar lista .gradio"), + ("pl.save_title", "Salvar lista .gradio"), + ("pl.filter", "Listas G Radio (*.gradio)"), + ("pl.default_name", "nova_lista.gradio"), + ("arbol.pautar_carpeta", "Programar pasta inteira (aleatória)"), + ("paut.tip_acumulado", "Tempo total acumulado na hora"), + ("paut.tip_corte", "Tempo deste bloco"), + ("paut.col_comercial", "Comercial"), + ("paut.col_dias", "STQQSSD"), + ("paut.col_inicio", "Início"), + ("paut.col_fin", "Fim"), + ("paut.col_ruta", "Caminho"), + ("paut.fecha_inicio", "Início"), + ("paut.fecha_fin", "Fim"), + ("paut.tip_limpiar", "Excluir comerciais vencidos"), + ("paut.confirm_caducados", "Deseja excluir todos os comerciais vencidos?"), + ("paut.info_caducados", "Foram excluídos {n} registros vencidos."), + ("paut.tipo_comerciales", "Comerciais"), + ("paut.tipo_eventos", "Eventos"), + ("paut.tipo_eventos_espera","Eventos em espera"), + ("paut.tip_subir", "Mover para cima"), + ("paut.tip_bajar", "Mover para baixo"), + ("paut.tip_eliminar", "Excluir selecionado da programação"), + ("paut.tip_play_cue", "Reproduzir na segunda placa de áudio"), + ("paut.tip_url", "Adicionar URL de streaming"), + ("paut.tip_hora", "Inserir marca de hora na programação"), + ("paut.tip_linea", "Entrada de linha"), + ("dlg.ruta_archivo", "Caminho do arquivo"), + ("dlg.copiar_ruta", "📋 Copiar caminho"), + ("dlg.ins_stream_title", "📡 Inserir streaming"), + ("dlg.url_lbl", "URL:"), + ("dlg.duracion_s", "Duração (s):"), + ("dlg.btn_insertar", "📡 Inserir"), + ("dlg.confirm_interrumpir","Há comerciais ou eventos reproduzindo.\nInterromper e reproduzir o áudio no deck?"), + ("dlg.filter_zip", "Arquivos ZIP"), + ("dlg.recovery_title", "Recuperação concluída"), + ("dlg.recovery_body", "✅ Configuração restaurada.\n{n} arquivos recuperados.\n\nReinicie o reprodutor para aplicar as mudanças."), + ("tool.auto_refill", "Automático — pausa o preenchimento automático da grade\nVerde=automático · Vermelho=pausado"), + ("tool.vaciar_playlist", "Limpar fila de reprodução"), + ("playlist.titulo", "🎵 Fila de reprodução [duplo clique=tocar · arrastar=reordenar · soltar arquivo=inserir]"), + ("playlist.total", "Total"), + ("panel.comerciales_tandas","📢 Comerciais — próximos blocos"), + ("panel.eventos_espera", "⏳ Eventos em espera"), + ("ctx.cue", "🎧 Pré-escuta (CUE)"), + ("ctx.ins_hora", "🕐 Inserir hora aqui"), + ("ctx.ins_stream", "📡 Inserir streaming aqui"), + ("ctx.load_list", "📋 Carregar lista .gradio aqui"), + ("ctx.regen", "🔄 Regenerar lista"), + ("ctx.del_cola", "🗑 Remover da fila"), + ("cue.win_title", "🎧 CUE: {title}"), + ("cue.btn_pause", "⏸ Pausa"), + ("cue.btn_resume", "▶ Retomar"), + ("cue.btn_stop", "■ Stop"), + ("tip.no_repeat", "Dias para trás consultados para evitar repetir uma faixa. 1 = só hoje, 3 = três dias (recomendado)."), + ("panel.eventos", "📅 Eventos"), + ("dlg.error", "Erro"), + ("dlg.backup_title", "Backup concluído"), + ("dlg.backup_body", "✅ Backup criado com sucesso.\n{n} arquivos salvos em:\n{path}"), + ("dlg.backup_err", "❌ Erro ao criar backup:\n{err}"), + ("dlg.sel_backup", "Selecionar backup GR"), + ("dlg.btn_restore", "Restaurar"), + ("dlg.recovery_err", "❌ Erro ao restaurar:\n{err}"), + ("dlg.token_title", "Token obrigatório"), + ("dlg.token_body", "⚠ O relay de internet requer um token de acesso.\nSem token, qualquer um que adivinhe o ID pode controlar o reprodutor.\n\nDigite um token no campo 'Token de acesso (gr-client)' antes de ativar o relay."), + // Fade + ("cfg.fundido_excl", "Pastas sem fade:"), + ("tip.fundido_excl", "Os arquivos nessas pastas tocam com início direto (sem crossfade), para não cortar o começo nem o final — útil para locuções/vinhetas."), + // IA + ("cfg.ia", "Inteligência Artificial:"), + ("tip.ia", "Habilita o assistente de IA integrado (OpenCode com modelos abertos gratuitos)."), + ("tip.ia_btn", "Abrir assistente IA para G-Radio"), + ("btn.ia", "IA"), + // Locutor Automático + ("cfg.locutor", "Locutor Automático:"), + ("tip.locutor", "Habilita a geração de locuções por IA feita por agentes externos (experimental). O estado é salvo em tmp/locutor_auto para que esses agentes o leiam."), + ("dlg.ia_warn_title", "Habilitar Inteligência Artificial"), + ("dlg.ia_warn_body", "Esta função usa OpenCode com modelos de IA abertos e gratuitos.\n\nO assistente pode ler e modificar arquivos de programação da rádio.\n\n⚠ O uso de IA pode gerar resultados inesperados. Revise as alterações\nantes de aplicá-las no ar.\n\nVocê aceita estas condições e deseja habilitar a IA?"), + ("dlg.ia_no_bin", "⚠ opencode não encontrado.\n\nInstale em: https://opencode.ai\n\nOu coloque o binário em:\n ~/.local/bin/opencode"), + ("dlg.ia_accept", "✅ Aceitar e habilitar"), + ("dlg.locutor_warn_title", "Habilitar Locutor Automático"), + ("dlg.locutor_warn_body", "O Locutor Automático gera locuções de rádio (voz IA) através de um agente de IA externo (Claude/OpenCode), não deste programa.\n\nEsta função só ativa a bandeira que esse agente lê para decidir se deve gerar áudio; sem um agente configurado e em execução, nada acontece.\n\n⚠ O agente pode ler e escrever arquivos de programação da rádio\n(grade, comerciais, eventos) para inserir as locuções geradas.\n\nVocê aceita estas condições e deseja habilitar o Locutor Automático?"), + ("dlg.locutor_accept", "✅ Aceitar e habilitar"), + ("preset.default_name", "Meu preset"), +]; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9121307 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pub mod models; +pub mod storage; +pub mod storage_parrilla; +pub mod storage_botonera; +pub mod duracion_audio; +pub mod audio_probe; +pub mod i18n; +pub mod skin; +pub mod ui; + +/// Lee el locale forzado desde ~/.gradio/data/tmp/gradio.config (línea 17). +/// Devuelve None si no está configurado o el archivo no existe. +pub fn locale_from_config() -> Option { + let path = dirs::home_dir()?.join(".gradio/data/tmp/gradio.config"); + let text = std::fs::read_to_string(path).ok()?; + text.lines().nth(16).map(|l| l.trim().to_string()).filter(|s| !s.is_empty()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b5e8293 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,9460 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// radio-player/src/main.rs +// Reproductor de Radio con GTK4 + GStreamer +// Maneja música, comerciales, fundido cruzado y control de volumen + +use gtk4::prelude::*; +use gtk4::{ + Application, ApplicationWindow, Box as GtkBox, Button, Label, Orientation, + Scale, Adjustment, ToggleButton, Frame, CssProvider, + ScrolledWindow, ListBox, ListBoxRow, GestureClick, + DragSource, DropTarget, + STYLE_PROVIDER_PRIORITY_APPLICATION, + Entry, DrawingArea, +}; + +use gdk4::{ContentProvider, FileList}; +use cairo; +use gtk4::gio; +use glib::types::StaticType; +use glib::{timeout_add_local}; +use gstreamer as gst; +use gstreamer::prelude::*; +use gstreamer_pbutils as gst_pbutils; +use gstreamer_app as gst_app; +use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use std::path::{Path, PathBuf}; +use std::fs; +use rand::seq::SliceRandom; +use chrono::{Local, Timelike}; +use anyhow::Result; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; +use log::{info, warn, error}; +use grpautaje::skin; + +mod servidor; +mod relay; +mod processor; +mod i18n; + +// DSP compartido entre todos los pipelines activos. +// Almacena el mismo Arc que AppState.dsp → update_config() afecta en tiempo real. +static PROC_DSP: std::sync::OnceLock>> + = std::sync::OnceLock::new(); + +fn proc_dsp_global() -> &'static std::sync::Mutex> { + PROC_DSP.get_or_init(|| std::sync::Mutex::new(None)) +} + +// Versión de config: se incrementa en cada apply() de la ventana de config. +// El pad probe de cada pipeline la compara con su versión local; si difiere, +// recarga la config del master y llama update_config() en su DspProcessor propio. +static PROC_CFG_VER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +// Control ON/OFF autoritativo — el botón lo escribe, las probes lo leen directo (sin lock). +// Separado de cfg.enabled para que el toggle nunca dependa de un mutex que pueda fallar. +static PROC_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +#[cfg(target_os = "windows")] +const EXE_EXT: &str = ".exe"; +#[cfg(not(target_os = "windows"))] +const EXE_EXT: &str = ""; + +// ─── Estructuras de datos ──────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +struct Track { + path: PathBuf, + duration_secs: f64, + title: String, +} + +#[derive(Debug, Clone)] +struct Commercial { + path: PathBuf, // ruta local (puede ser vacía si es URL) + url: Option, // URL de streaming http/https + duration_secs: f64, +} + +/// Detecta si una cadena es una URL de streaming (http:// o https://) +fn is_url(s: &str) -> bool { + let s = s.trim(); + s.starts_with("http://") || s.starts_with("https://") +} + +/// Valida dias activos: campo "1234567" donde 1=Lun ... 7=Dom. +fn valid_day_mask(mask: &str) -> bool { + use chrono::Datelike; + let dow = chrono::Local::now().weekday().number_from_monday(); + mask.trim().chars().any(|c| c.to_digit(10) == Some(dow)) +} + +/// Valida rango de fechas YYYYMMDD. Retorna true si hoy esta entre start y end (inclusive). +fn valid_date_range(start: &str, end: &str) -> bool { + let today = chrono::Local::now().format("%Y%m%d").to_string(); + let s = start.trim().trim_end_matches('\r'); + let e = end.trim().trim_end_matches('\r'); + if (s.is_empty() || s == "0") && (e.is_empty() || e == "0") { return true; } + let ok_start = s.is_empty() || s == "0" || today.as_str() >= s; + let ok_end = e.is_empty() || e == "0" || today.as_str() <= e; + ok_start && ok_end +} + +/// Parsea una linea de archivo .com / eventoslist / eventos-esperalist. +/// +/// Formatos soportados: +/// +/// 1. Formato .com con validacion de dias y fechas: +/// /ruta/audio.mp3|1234567|20260101|20260401 +/// /ruta/carpeta/|1234567|20260101|20260401 <- carpeta: elige aleatorio +/// +/// 2. Formato legado mplayer (streams): +/// -cache 300 -endpos 900 https://host:port/stream +/// +/// 3. Formato nuevo stream: URL seguida de duracion en segundos: +/// https://host:port/stream 900 +/// +/// 4. Archivo local con duracion TAB: +/// /ruta/audio.mp3\t03:45.000 +/// +/// Retorna None si la linea no aplica hoy (dia o fecha fuera de rango). + +/// Carga un archivo .gradio: si `at_index` es Some(idx) inserta en esa posición, +/// si es None reemplaza toda la playlist4. +fn apply_gradio_file(state: &SharedState, gradio_path: &Path, at_index: Option) { + let playlist_path = dirs::home_dir().unwrap().join(".gradio/data/tmp/playlist4"); + let Ok(content) = fs::read_to_string(gradio_path) else { + error!("apply_gradio_file: no se pudo leer {:?}", gradio_path); + return; + }; + let new_tracks: Vec = content + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { return None; } + let (path_str, dur_str) = if let Some(pos) = line.find('\t') { + (line[..pos].trim(), line[pos+1..].trim()) + } else { + (line, "") + }; + if path_str.is_empty() { return None; } + let path = PathBuf::from(path_str); + let title = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Audio") + .to_string(); + let duration_secs: f64 = dur_str + .parse().unwrap_or_else(|_| { + let parts: Vec<&str> = dur_str.splitn(2, ':').collect(); + if parts.len() == 2 { + let m: f64 = parts[0].parse().unwrap_or(0.0); + let s: f64 = parts[1].trim_end_matches(".000").parse().unwrap_or(0.0); + m * 60.0 + s + } else { 0.0 } + }); + Some(Track { path, title, duration_secs }) + }) + .collect(); + + let mut st = state.lock().unwrap(); + match at_index { + None => { + // Reemplazar toda la playlist + st.playlist = new_tracks; + info!("Playlist reemplazada con {} temas desde {:?}", st.playlist.len(), gradio_path); + } + Some(idx) => { + // Insertar desde la posición idx + let insert_at = idx.min(st.playlist.len()); + for (i, track) in new_tracks.into_iter().enumerate() { + st.playlist.insert(insert_at + i, track); + } + info!("Insertados temas desde {:?} en posición {}", gradio_path, insert_at); + } + } + st.playlist_version = st.playlist_version.wrapping_add(1); + save_playlist_to_file(&st.playlist, &playlist_path); +} + +fn parse_commercial_line(line: &str) -> Option { + let line = line.trim().trim_end_matches('\r').trim(); + if line.is_empty() || line.starts_with('#') { return None; } + + // -- Formato legado mplayer: -cache N -endpos N https://... -------------- + if line.contains("-endpos") { + let mut dur_secs: f64 = 0.0; + let mut url_found: Option = None; + let parts: Vec<&str> = line.split_whitespace().collect(); + let mut i = 0; + while i < parts.len() { + if parts[i] == "-endpos" { + if let Some(v) = parts.get(i + 1) { + dur_secs = v.parse::().unwrap_or(0.0); + i += 2; + continue; + } + } else if is_url(parts[i]) { + url_found = Some(parts[i].to_string()); + } + i += 1; + } + if let Some(url) = url_found { + return Some(Commercial { path: PathBuf::new(), url: Some(url), duration_secs: dur_secs }); + } + } + + // -- Formato nuevo stream: https://... --------------- + if line.starts_with("http://") || line.starts_with("https://") { + let mut parts = line.splitn(2, |c: char| c.is_whitespace()); + let url = parts.next().unwrap_or("").trim().to_string(); + let rest = parts.next().unwrap_or("").trim(); + let dur = rest.parse::().unwrap_or(0.0); + return Some(Commercial { path: PathBuf::new(), url: Some(url), duration_secs: dur }); + } + + // -- Formato .com con metadatos: ruta|dias|inicio|fin -------------------- + if line.contains('|') { + let parts: Vec<&str> = line.splitn(4, '|').collect(); + let file_part = parts.get(0).unwrap_or(&"").trim(); + let days_mask = parts.get(1).unwrap_or(&"").trim(); + let start_date = parts.get(2).unwrap_or(&"").trim(); + let end_date = parts.get(3).unwrap_or(&"").trim().trim_end_matches('\r'); + + if file_part.is_empty() { return None; } + + // Validar dia de semana (si mask es "0" o vacia = todos los dias) + if !days_mask.is_empty() && days_mask != "0" && !valid_day_mask(days_mask) { + return None; + } + + // Validar rango de fechas + if !valid_date_range(start_date, end_date) { + return None; + } + + // Normalizar ruta: quitar /* o / al final para que is_dir() funcione + let clean_file = file_part + .trim_end_matches('/') + .trim_end_matches('*') + .trim_end_matches('/'); + return Some(Commercial { + path: PathBuf::from(clean_file), + url: None, + duration_secs: 0.0, + }); + } + + // -- Archivo local con duracion TAB: /ruta/audio.mp3\tMM:SS.mmm ---------- + let (file_part, dur_part) = if let Some(pos) = line.find('\t') { + (line[..pos].trim(), line[pos+1..].trim()) + } else { + (line, "") + }; + + if file_part.is_empty() { return None; } + + let clean_path = sanitize_path(file_part); + let duration_secs = if dur_part.is_empty() { + 0.0 + } else { + dur_part.parse::().unwrap_or_else(|_| parse_duration_str(dur_part)) + }; + + Some(Commercial { path: PathBuf::from(&clean_path), url: None, duration_secs }) +} + +#[derive(Debug, Clone, PartialEq)] +enum PlaybackState { + Stopped, + Playing, + Paused, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ActiveDeck { + A, + B, +} + +// ─── Estado compartido de la aplicación ────────────────────────────────────── + +struct AppState { + // Playlist y posición + playlist: Vec, + current_index: usize, + + // Título del track actualmente en reproducción (para la UI) + current_title: String, + // Versión de la playlist: se incrementa en cada modificación + // para que el timer detecte cambios aunque el nº de filas sea igual + playlist_version: u64, + + // Estado de cada deck (A=par, B=impar para crossfade) + deck_a_state: PlaybackState, + deck_b_state: PlaybackState, + active_deck: ActiveDeck, + + // Pipelines de GStreamer + pipeline_a: Option, + pipeline_b: Option, + // Pipeline para comerciales/jingles (visible en la UI mientras se reproduce) + pipeline_comm: Option, + pipeline_cue: Option, // preescucha CUE (tarjeta secundaria) + pipeline_botonera: Option, // botonera remota — corre sobre cualquier audio + + // Volumen (0.0 – 1.0) + upvol: f64, + downvol: f64, + duck_active: bool, + + // Tiempo de fundido en segundos + crossfade_secs: f64, + + // Comerciales + #[allow(dead_code)] + commercials: Vec, + commercials_playing: bool, + stop_after_track: bool, // stop a fin de tema + stopped_after_a: bool, // deck A se detuvo por stop_after_track (para UI) + stopped_after_b: bool, // deck B se detuvo por stop_after_track (para UI) + loop_deck_a: bool, // loop infinito del deck A + loop_deck_b: bool, // loop infinito del deck B + // Eventos emergentes + eventos_playing: bool, // true mientras se procesa eventoslist + // Señal para interrumpir un streaming en curso (botón Siguiente) + stream_skip: bool, + // Señal para abortar el bloque de pautaje completo (drag a deck durante comerciales) + abort_pautaje: bool, + // Token de cancelación del crossfade en curso — se reemplaza en cada nuevo crossfade + crossfade_cancel: Arc, + // Evita lanzar el crossfade automático más de una vez por track + crossfade_triggered: bool, + // Nombre del audio reproduciéndose en pipeline_comm (para la UI) + comm_title: String, + // Track musical activo: ruta e instante de inicio (para log con tiempo real) + current_track_path: PathBuf, + current_track_start: std::time::Instant, + station_name: String, + pisador_track_count: u32, // temas reproducidos desde último pisador + vu_active: bool, // VU meter activo + vu_level_l: f64, // nivel canal izquierdo (0.0-1.0 lineal, con decay visual) + vu_level_r: f64, // nivel canal derecho (con decay visual) + vu_level_l_raw: f64, // nivel crudo sin decay (para detección de silencio) + vu_level_r_raw: f64, + vu_last_level_msg: std::time::Instant, // última vez que llegó un mensaje level de GStreamer + // Detector de silencio sostenido (audio atípico que no genera EOS) + silence_secs: f64, + silence_since_a: Option, + silence_since_b: Option, + // Detector de posición estancada (pipeline Playing pero pos no avanza → archivo pegado) + stuck_pos_a: f64, + stuck_since_a: Option, + stuck_pos_b: f64, + stuck_since_b: Option, + // Marca la última vez que disparó el timer (para detectar suspensión del main loop en ARM/Wayland) + last_timer_tick: std::time::Instant, + // Procesador DSP multiband (None = desactivado) + dsp: Option, +} + +impl AppState { + fn new() -> Self { + Self { + playlist: Vec::new(), + current_index: 0, + deck_a_state: PlaybackState::Stopped, + deck_b_state: PlaybackState::Stopped, + active_deck: ActiveDeck::A, + pipeline_a: None, + pipeline_b: None, + pipeline_comm: None, + pipeline_cue: None, + pipeline_botonera: None, + current_title: String::new(), + playlist_version: 0, + upvol: 1.0, + downvol: 0.3, + duck_active: false, + crossfade_secs: 3.0, + commercials: Vec::new(), + commercials_playing: false, + stop_after_track: false, + stopped_after_a: false, + stopped_after_b: false, + loop_deck_a: false, + loop_deck_b: false, + eventos_playing: false, + stream_skip: false, + abort_pautaje: false, + crossfade_cancel: Arc::new(AtomicBool::new(false)), + crossfade_triggered: false, + comm_title: String::new(), + current_track_path: PathBuf::new(), + current_track_start: std::time::Instant::now(), + station_name: String::new(), + pisador_track_count: 0, + vu_active: true, + vu_level_l: 0.0, + vu_level_r: 0.0, + vu_level_l_raw: -100.0, // dB crudo; -100 = silencio absoluto + vu_level_r_raw: -100.0, + vu_last_level_msg: std::time::Instant::now(), + silence_secs: 5.0, + silence_since_a: None, + silence_since_b: None, + stuck_pos_a: -1.0, + stuck_since_a: None, + stuck_pos_b: -1.0, + stuck_since_b: None, + last_timer_tick: std::time::Instant::now(), + dsp: None, + } + } +} + +type SharedState = Arc>; + +// ─── Helpers de archivos ────────────────────────────────────────────────────── + +fn home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")) +} + +fn read_f64_from_file(path: &Path) -> f64 { + fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(100.0) +} + +fn contar_lineas_archivo(path: &Path) -> usize { + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .filter(|l| !l.trim().is_empty()) + .count() +} + +fn parse_duration_str(s: &str) -> f64 { + // Formato: MM:SS.mmm o HH:MM:SS + let s = s.trim(); + let parts: Vec<&str> = s.split(':').collect(); + match parts.len() { + 2 => { + // MM:SS.mmm + let mins: f64 = parts[0].parse().unwrap_or(0.0); + let secs: f64 = parts[1].parse().unwrap_or(0.0); + mins * 60.0 + secs + } + 3 => { + // HH:MM:SS or 00:MM:SS.mmm + let h: f64 = parts[0].parse().unwrap_or(0.0); + let m: f64 = parts[1].parse().unwrap_or(0.0); + let sec: f64 = parts[2].parse().unwrap_or(0.0); + h * 3600.0 + m * 60.0 + sec + } + _ => 0.0, + } +} + +fn load_playlist(path: &Path) -> Vec { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + warn!("No se pudo leer playlist {}: {}", path.display(), e); + return Vec::new(); + } + }; + + content + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(2, '\t').collect(); + if parts.len() < 2 { + // Try splitting by multiple spaces + let mut it = line.rsplitn(2, " "); + let dur_s = it.next()?.trim(); + let file_path = it.next()?.trim(); + let _title = Path::new(file_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(file_path) + .to_string(); + let file_path = sanitize_path(file_path); + let title = Path::new(&file_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&file_path) + .to_string(); + Some(Track { + path: PathBuf::from(&file_path), + duration_secs: parse_duration_str(dur_s), + title, + }) + } else { + let raw_path = parts[0].trim().trim_end_matches('\r'); + let dur_s = parts[1].trim().trim_end_matches('\r'); + // Ignorar líneas con ruta vacía + if raw_path.is_empty() { return None; } + // Detectar ruta duplicada: /a/b.mp3/a/b.mp3 + let file_path = sanitize_path(raw_path); + let title = Path::new(&file_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&file_path) + .to_string(); + Some(Track { + path: PathBuf::from(&file_path), + duration_secs: parse_duration_str(dur_s), + title, + }) + } + }) + .collect() +} + +/// Limpia una ruta de archivo: quita \r y detecta rutas duplicadas +/// del tipo "/ruta/x.mp3/ruta/x.mp3" devolviendo solo la primera mitad. +fn sanitize_path(raw: &str) -> String { + let raw = raw.trim().trim_end_matches('\r'); + for ext in &["mp3", "MP3", "wav", "WAV", "flac", "FLAC", "m4a", "M4A", "ogg", "OGG"] { + let needle = format!(".{}/", ext); + if let Some(p) = raw.find(&needle) { + return raw[..p + needle.len() - 1].to_string(); // hasta ".ext" sin el "/" + } + } + raw.to_string() +} + +/// Guarda la playlist actual (desde AppState) al archivo playlist4. +/// Se llama cada vez que la lista se modifica por drag, reorden, o doble click. +fn save_playlist_to_file(playlist: &[Track], path: &Path) { + use std::io::Write; + let content: String = playlist + .iter() + .map(|t| format!("{}\t{}\n", sanitize_path(&t.path.to_string_lossy()), secs_to_display(t.duration_secs))) + .collect(); + if let Ok(mut f) = fs::OpenOptions::new() + .write(true).create(true).truncate(true) + .open(path) + { + let _ = f.write_all(content.as_bytes()); + } +} + +/// Inserta un archivo de audio en la playlist en la posición dada. +/// Detecta la duración desde GStreamer (discoverer) o la deja en 0. +fn make_track_from_path(path: PathBuf) -> Track { + let title = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("desconocido") + .to_string(); + // Intentar obtener duración con GStreamer discoverer + let duration_secs = { + let uri = format!("file://{}", path.display()); + if let Ok(d) = gst_pbutils::Discoverer::new(gst::ClockTime::from_seconds(5)) { + if let Ok(info) = d.discover_uri(&uri) { + info.duration() + .map(|t| t.seconds() as f64) + .unwrap_or(0.0) + } else { 0.0 } + } else { 0.0 } + }; + Track { path, duration_secs, title } +} + +/// Inserta una lista de paths en la playlist en `insert_at`, validando cada +/// archivo con symphonia para descartar audios corruptos antes de +/// agregarlos. Devuelve cuántos se insertaron efectivamente. +/// +/// Centraliza la validación para todas las vías de entrada del usuario: +/// drag-drop al listbox/scroll/frame de la cola, drop al catch-all de +/// lists_row, y URIs llegadas como text/uri-list. +fn insert_paths_validated( + paths: Vec, + insert_at: usize, + st: &mut AppState, + playlist_path: &Path, +) -> usize { + let mut inserted = 0; + for path in paths { + if !path.exists() { continue; } + let ext = path.extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !matches!(ext.as_str(), "mp3"|"wav"|"ogg"|"flac"|"aac"|"m4a") { + continue; + } + if !grpautaje::audio_probe::is_playable(&path) { + warn!("Audio inválido descartado al insertar: {}", path.display()); + continue; + } + let track = make_track_from_path(path); + st.playlist.insert(insert_at + inserted, track); + inserted += 1; + } + if inserted > 0 { + save_playlist_to_file(&st.playlist, playlist_path); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + inserted +} + +/// Elimina la primera línea no vacía de un archivo de texto plano. +/// Se llama justo antes de reproducir cada tema/comercial para que +/// la lista siempre refleje lo que FALTA reproducir. +fn remove_first_line_from_file(path: &Path) { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return, + }; + // Saltar la primera línea no vacía y reconstruir el resto + let new_content: Vec<&str> = content + .lines() + .skip_while(|l| l.trim().is_empty()) + .skip(1) + .collect(); + let result = new_content.join("\n"); + let result = if result.is_empty() { String::new() } else { format!("{}\n", result) }; + let _ = fs::write(path, result); +} + +// ─── Funciones de reporte y control histórico ──────────────────────────────── + +/// Escribe una línea en el reporte diario de parrilla musical. +/// Formato: "YYYY-MM-DD,HH:MM:SS,/ruta/audio.mp3,duracion,seg,parrilla" +fn log_parrilla(path: &Path, duration_secs: f64) { + // duration_secs = tiempo REAL al aire (pipeline_position al momento de cerrar) + let now = Local::now(); + let home = home_dir(); + let reporte_dir = home.join(".gradio/data/reporte"); + let _ = fs::create_dir_all(&reporte_dir); + let report_path = reporte_dir.join(format!("GR6-parrilla-{}.txt", now.format("%Y-%m-%d"))); + let line = format!( + "{},{},{},{},seg,parrilla\n", + now.format("%Y-%m-%d"), + now.format("%H:%M:%S"), + path.display(), + duration_secs as u64, + ); + use std::io::Write; + if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&report_path) { + let _ = f.write_all(line.as_bytes()); + } + info!("Parrilla log: {}", line.trim()); +} + +/// Escribe una línea en el reporte diario de comerciales. +/// Formato: "YYYY-MM-DD,HH:MM:SS,/ruta/comercial.mp3,duracion,seg,comercial" +fn log_comercial(path: &Path, duration_secs: f64) { + let now = Local::now(); + let home = home_dir(); + let reporte_dir = home.join(".gradio/data/reporte"); + let _ = fs::create_dir_all(&reporte_dir); + + let filename = format!( + "GR6-comercial-{}.txt", + now.format("%Y-%m-%d") + ); + let report_path = reporte_dir.join(filename); + + let line = format!( + "{},{},{},{},seg,comercial\n", + now.format("%Y-%m-%d"), + now.format("%H:%M:%S"), + path.display(), + duration_secs as u64, + ); + + use std::io::Write; + if let Ok(mut f) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&report_path) + { + let _ = f.write_all(line.as_bytes()); + } + info!("Comercial log: {}", line.trim()); +} + +/// Registra el nombre del archivo en .historico.mus dentro de su carpeta. +/// Cada emisión se registra siempre, permitiendo auditar repeticiones. +fn log_historico(track_path: &Path) { + let dir = match track_path.parent() { + Some(d) => d, + None => return, + }; + let filename = match track_path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => return, + }; + let hist_path = dir.join(".historico.mus"); + + use std::io::Write; + if let Ok(mut f) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&hist_path) + { + let _ = f.write_all(format!("{}\n", filename).as_bytes()); + } + info!("Histórico: {} → {}", filename, hist_path.display()); +} + +/// Carga los eventos en espera desde el archivo eventos-esperalist. +/// Reutiliza la estructura Commercial (ruta + duración). +fn load_eventos(path: &Path) -> Vec { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + info!("load_eventos: no se pudo leer {:?}: {}", path, e); + return Vec::new(); + } + }; + let result: Vec = content + .lines() + .filter_map(|line| parse_commercial_line(line)) + .collect(); + info!("load_eventos({:?}): {} items cargados", path, result.len()); + result +} + +/// Escribe una línea en el reporte diario de eventos en espera. +fn log_evento(path: &Path, duration_secs: f64) { + let now = Local::now(); + let home = home_dir(); + let reporte_dir = home.join(".gradio/data/reporte"); + let _ = fs::create_dir_all(&reporte_dir); + let report_path = reporte_dir.join(format!("GR6-evento-{}.txt", now.format("%Y-%m-%d"))); + let line = format!( + "{},{},{},{},seg,evento +", + now.format("%Y-%m-%d"), + now.format("%H:%M:%S"), + path.display(), + duration_secs as u64, + ); + use std::io::Write; + if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&report_path) { + let _ = f.write_all(line.as_bytes()); + } + info!("Evento log: {}", line.trim()); +} + +fn load_commercials(path: &Path) -> Vec { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + info!("load_commercials: no se pudo leer {:?}: {}", path, e); + return Vec::new(); + } + }; + let result: Vec = content + .lines() + .filter_map(|line| parse_commercial_line(line)) + .collect(); + info!("load_commercials({:?}): {} items cargados", path, result.len()); + result +} + +fn random_file_from_dir(dir: &Path) -> Option { + let entries: Vec = fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.is_file() + && matches!( + p.extension().and_then(|e| e.to_str()), + Some("mp3") | Some("wav") | Some("ogg") | Some("flac") + ) + }) + .collect(); + + let mut rng = rand::thread_rng(); + entries.choose(&mut rng).cloned() +} + + +/// Para una línea del archivo de cola, devuelve el texto a mostrar en la lista. +/// Si es carpeta (termina en /* o / o es directorio): "nombre-random (MM:SS)" +/// Si es archivo: nombre sin extensión +/// Si es URL: la URL +/// Extrae la ruta de una línea de comercialeslist4/eventoslist/eventos-esperalist. +/// Soporta `path|day-mask|start|end` y `path\tdur`. +fn parse_path_from_queue_line(raw: &str) -> &str { + let p1 = raw.splitn(2, '\t').next().unwrap_or(raw).trim(); + p1.split('|').next().unwrap_or(p1).trim() +} + +/// Intercambia dos líneas no vacías por índice (sólo cuenta no vacías). +/// Preserva las líneas vacías intermedias en su posición original. +fn swap_lines_in_file(path: &std::path::Path, i: usize, j: usize) { + let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return }; + let mut lines: Vec = content.lines().map(String::from).collect(); + let non_empty: Vec = lines.iter().enumerate() + .filter(|(_, l)| !l.trim().is_empty()) + .map(|(idx, _)| idx).collect(); + if i >= non_empty.len() || j >= non_empty.len() || i == j { return; } + let real_i = non_empty[i]; + let real_j = non_empty[j]; + lines.swap(real_i, real_j); + let mut out = lines.join("\n"); + if !out.ends_with('\n') { out.push('\n'); } + let _ = fs::write(path, out); +} + +/// Decodifica percent-encoding de URIs (file:// GStreamer): %20 → ' ', etc. +/// Maneja UTF-8 multi-byte correctamente (ej. %C3%A9 → é). +fn percent_decode(s: &str) -> String { + let chars: Vec = s.bytes().collect(); + let mut bytes: Vec = Vec::with_capacity(chars.len()); + let mut i = 0; + while i < chars.len() { + if chars[i] == b'%' && i + 2 < chars.len() { + if let Ok(hex) = std::str::from_utf8(&chars[i+1..i+3]) { + if let Ok(byte) = u8::from_str_radix(hex, 16) { + bytes.push(byte); + i += 3; + continue; + } + } + } + bytes.push(chars[i]); + i += 1; + } + String::from_utf8_lossy(&bytes).into_owned() +} + +/// Duración en segundos para una línea de cola. 0.0 = stream/no disponible. +/// Usa cache thread-local para no relanzar ffprobe en cada rebuild. +fn duration_for_queue_line(raw: &str) -> f64 { + use std::cell::RefCell; + use std::collections::HashMap; + thread_local! { + static DUR_CACHE: RefCell> = RefCell::new(HashMap::new()); + } + let path_part = parse_path_from_queue_line(raw); + if path_part.starts_with("http://") || path_part.starts_with("https://") { + return 0.0; // stream — sin duración + } + // Si la línea trae duración explícita en formato `\tHH:MM:SS` o `\tMM:SS.mmm`, usarla + if let Some(rest) = raw.split('\t').nth(1) { + let parts: Vec<&str> = rest.trim().splitn(3, ':').collect(); + let secs: f64 = match parts.as_slice() { + [h, m, s] => h.parse::().unwrap_or(0.0) * 3600.0 + + m.parse::().unwrap_or(0.0) * 60.0 + + s.trim_end_matches(".000").parse::().unwrap_or(0.0), + [m, s] => m.parse::().unwrap_or(0.0) * 60.0 + + s.trim_end_matches(".000").parse::().unwrap_or(0.0), + _ => 0.0, + }; + if secs > 0.0 { return secs; } + } + let key = path_part.to_string(); + if let Some(d) = DUR_CACHE.with(|c| c.borrow().get(&key).copied()) { + return d; + } + let normalized = path_part.trim_end_matches('/').trim_end_matches('*').trim_end_matches('/'); + let p = std::path::Path::new(normalized); + let dur = if p.is_dir() { + first_audio_duration_in_dir(p).unwrap_or(0.0) + } else if p.is_file() { + std::process::Command::new("ffprobe") + .args(["-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", normalized]) + .output().ok() + .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::().ok()) + .unwrap_or(0.0) + } else { + 0.0 + }; + DUR_CACHE.with(|c| c.borrow_mut().insert(key, dur)); + dur +} + +fn display_name_for_queue_line(raw: &str) -> String { + let path_part = raw.splitn(2, '\t').next().unwrap_or(raw).trim(); + let path_part = path_part.split('|').next().unwrap_or(path_part).trim(); + + if path_part.starts_with("http://") || path_part.starts_with("https://") { + return path_part.to_string(); + } + + // Normalizar: quitar /* y / al final + let normalized = path_part + .trim_end_matches('/') + .trim_end_matches('*') + .trim_end_matches('/'); + + let p = std::path::Path::new(normalized); + + if p.is_dir() { + // Nombre de la carpeta + "-random" + duración del primer audio + let folder_name = p.file_name() + .and_then(|s| s.to_str()) + .unwrap_or(normalized); + let dur_str = first_audio_duration_in_dir(p) + .map(|d| format!(" ({:02}:{:02})", d as u64 / 60, d as u64 % 60)) + .unwrap_or_default(); + return format!("{}-random{}", folder_name, dur_str); + } + + // Archivo normal + p.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(path_part) + .to_string() +} + +/// Obtiene la duración del primer archivo de audio en un directorio. +fn first_audio_duration_in_dir(dir: &std::path::Path) -> Option { + let mut entries: Vec = fs::read_dir(dir).ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file() && matches!( + p.extension().and_then(|x| x.to_str()), + Some("mp3") | Some("wav") | Some("ogg") | Some("flac") | Some("m4a") + )) + .collect(); + entries.sort(); + let first = entries.into_iter().next()?; + let out = std::process::Command::new("ffprobe") + .args(["-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + first.to_str()?]) + .output().ok()?; + String::from_utf8_lossy(&out.stdout).trim().parse::().ok() +} + +fn secs_to_display(secs: f64) -> String { + let total = secs as u64; + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + if h > 0 { + format!("{:02}:{:02}:{:02}", h, m, s) + } else { + format!("{:02}:{:02}", m, s) + } +} + +// ─── Ventana de configuración del procesador DSP ───────────────────────────── + +fn abrir_ventana_procesador(parent: &ApplicationWindow, state: SharedState) { + use std::rc::Rc; + use std::cell::RefCell; + use std::sync::Arc; + use processor::ui::band_strip::BandStrip; + + // Obtener config actual del DspProcessor. + // Siempre leemos del global PROC_DSP (tiene la config más reciente en memoria + // independientemente de si el procesador está ON u OFF). Fallback a disco. + let cfg_arc: Arc> = { + if let Ok(g) = proc_dsp_global().try_lock() { + if let Some(ref dsp) = *g { + if let Ok(d) = dsp.try_lock() { + Arc::new(Mutex::new(d.cfg.clone())) + } else { + Arc::new(Mutex::new(processor::ProcessorConfig::cargar())) + } + } else { + Arc::new(Mutex::new(processor::ProcessorConfig::cargar())) + } + } else { + Arc::new(Mutex::new(processor::ProcessorConfig::cargar())) + } + }; + + // Función que aplica cambios al DspProcessor vivo + // El lock se sostiene el mínimo tiempo posible (solo para clonar la config). + let apply: Rc = { + let cfg_arc = cfg_arc.clone(); + let state = state.clone(); + Rc::new(move || { + // 1. Clonar config con lock mínimo + let mut cfg = match cfg_arc.lock() { + Ok(c) => c.clone(), + Err(_) => return, + }; // lock liberado aquí + // 2. Sincronizar cfg.enabled con PROC_ENABLED (fuente autoritativa). + // La ventana de config nunca controla enabled — ese bit pertenece al botón. + cfg.enabled = PROC_ENABLED.load(std::sync::atomic::Ordering::Relaxed); + cfg.guardar(); + // Señal a todos los pipelines activos para que recarguen la config + PROC_CFG_VER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // Actualizar el DSP compartido + if let Ok(g) = proc_dsp_global().try_lock() { + if let Some(ref dsp) = *g { + if let Ok(mut d) = dsp.try_lock() { + d.update_config(cfg.clone()); + } + } + } + // También actualizar AppState.dsp por consistencia + if let Ok(st) = state.try_lock() { + if let Some(ref dsp) = st.dsp { + if let Ok(mut d) = dsp.try_lock() { + d.update_config(cfg); + } + } + } + }) + }; + + // CSS del procesador + let css_str = concat!( + "window { background-color: #0e0e12; }", + ".rack-top { background-color:#16161c; border-bottom:2px solid #2a2a3a; padding:5px 10px; min-height:60px; }", + ".rack-title { font-family:Monospace; font-size:13px; font-weight:bold; color:#e8830a; letter-spacing:3px; }", + ".rack-subtitle { font-family:Monospace; font-size:7px; color:#3a3a50; letter-spacing:2px; }", + ".rack-center { background-color:#181820; border:1px solid #252535; border-radius:4px; padding:4px; }", + ".band-strip { background-color:#1e1e28; border:1px solid #2a2a38; border-radius:3px; padding:4px 3px; }", + ".band-num { font-family:Monospace; font-size:11px; font-weight:bold; color:#e8830a; }", + ".band-freq { font-family:Monospace; font-size:7px; color:#3a5060; }", + ".meter-lbl { font-family:Monospace; font-size:7px; color:#344050; min-width:16px; }", + ".band-check { font-family:Monospace; font-size:8px; color:#607080; }", + ".side-panel { background-color:#14141c; border:1px solid #222230; border-radius:3px; padding:6px 5px; min-width:92px; }", + ".side-lbl { font-family:Monospace; font-size:8px; color:#445060; }", + ".io-lbl { font-family:Monospace; font-size:9px; color:#e8830a; }", + "separator { background-color:#222232; margin:2px 0; min-height:1px; }", + ".btn-load { font-family:Monospace; font-size:9px; font-weight:bold; color:#20c060; background:#0e1e0e; border:1px solid #185028; border-radius:3px; padding:2px 8px; }", + ".btn-save { font-family:Monospace; font-size:9px; font-weight:bold; color:#2090d0; background:#0e1420; border:1px solid #183048; border-radius:3px; padding:2px 8px; }", + ); + let css_prov = CssProvider::new(); + css_prov.load_from_data(css_str); + + let win = gtk4::Window::builder() + .title(i18n::tr("win.processor")) + .transient_for(parent) + .default_width(1100) + .default_height(560) + .build(); + + gtk4::style_context_add_provider_for_display( + >k4::prelude::WidgetExt::display(&win), + &css_prov, + STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let root = GtkBox::new(Orientation::Vertical, 4); + root.set_margin_start(6); root.set_margin_end(6); + root.set_margin_top(4); root.set_margin_bottom(6); + + // ── Sección PRESETS ────────────────────────────────────────────────────── + let presets_list = Rc::new(RefCell::new(processor::presets::all_presets())); + let combo_preset = gtk4::ComboBoxText::new(); + for p in presets_list.borrow().iter() { combo_preset.append(Some(&p.name), &p.name); } + // Restaurar el último preset usado + { + let last = cfg_arc.lock().unwrap().last_preset.clone(); + if last.is_empty() || !combo_preset.set_active_id(Some(&last)) { + combo_preset.set_active(Some(0)); + } + } + + let btn_load = Button::with_label("▶ LOAD"); + btn_load.set_css_classes(&["btn-load"]); + let btn_save = Button::with_label("● SAVE"); + btn_save.set_css_classes(&["btn-save"]); + + let preset_row = GtkBox::new(Orientation::Horizontal, 6); + preset_row.set_margin_bottom(4); + preset_row.append(&Label::builder().label(i18n::tr("preset.label")).css_classes(["side-lbl"]).build()); + preset_row.append(&combo_preset); + preset_row.append(&btn_load); + preset_row.append(&btn_save); + root.append(&preset_row); + + // Obtener bandas iniciales + let (nb_init, freqs_init) = { + let c = cfg_arc.lock().unwrap(); + (c.num_bands.clamp(2, 6), c.crossover_freqs) + }; + + let freq_labels = processor::ui::band_freq_labels(&freqs_init); + + // Arcos locales para los VU meters de la ventana de configuración. + // El timer los sincroniza desde el DspProcessor cada 33 ms. + let meter_arcs: Vec<(Arc, Arc)> = + (0..6).map(|_| ( + Arc::new(processor::AtomicF32::new(0.0)), + Arc::new(processor::AtomicF32::new(0.0)), + )).collect(); + + let strips: Vec = (0..6).map(|b| { + BandStrip::new(b, &freq_labels[b], meter_arcs[b].0.clone(), meter_arcs[b].1.clone()) + }).collect(); + + // Conectar knobs → cfg → DSP + for (b, strip) in strips.iter().enumerate() { + { let ca = cfg_arc.clone(); let ap = apply.clone(); + strip.threshold.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].threshold_db = v; } ap(); }); } + { let ca = cfg_arc.clone(); let ap = apply.clone(); + strip.ratio.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].ratio = v; } ap(); }); } + { let ca = cfg_arc.clone(); let ap = apply.clone(); + strip.attack.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].attack_ms = v; } ap(); }); } + { let ca = cfg_arc.clone(); let ap = apply.clone(); + strip.release.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].release_ms = v; } ap(); }); } + { let ca = cfg_arc.clone(); let ap = apply.clone(); + strip.makeup.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].makeup_db = v; } ap(); }); } + { let ca = cfg_arc.clone(); let ap = apply.clone(); + strip.clip_lvl.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].clip_level_db = v; } ap(); }); } + { let ca = cfg_arc.clone(); let ap = apply.clone(); + strip.clip_en.connect_toggled(move |cb| { if let Ok(mut c) = ca.lock() { c.bands[b].clip_enable = cb.is_active(); } ap(); }); } + { let ca = cfg_arc.clone(); let ap = apply.clone(); + strip.enabled.connect_toggled(move |cb| { if let Ok(mut c) = ca.lock() { c.bands[b].enabled = cb.is_active(); } ap(); }); } + } + + // Inicializar valores de knobs desde config + { + let c = cfg_arc.lock().unwrap(); + for (b, strip) in strips.iter().enumerate() { + let bd = &c.bands[b]; + strip.threshold.set_value(bd.threshold_db); + strip.ratio .set_value(bd.ratio); + strip.attack .set_value(bd.attack_ms); + strip.release .set_value(bd.release_ms); + strip.makeup .set_value(bd.makeup_db); + strip.clip_lvl .set_value(bd.clip_level_db); + strip.clip_en .set_active(bd.clip_enable); + strip.enabled .set_active(bd.enabled); + strip.set_active(b < nb_init); + } + } + + let bands_box = GtkBox::builder() + .orientation(Orientation::Horizontal).spacing(3) + .hexpand(true).vexpand(true) + .css_classes(["rack-center"]).build(); + for s in &strips { bands_box.append(&s.widget); } + + // SpinButton de bandas + let sp_bands = gtk4::SpinButton::with_range(2.0, 6.0, 1.0); + sp_bands.set_value(nb_init as f64); + { + let ca = cfg_arc.clone(); let ap = apply.clone(); + let strips_c = strips.clone(); + sp_bands.connect_value_changed(move |s| { + let nb = s.value() as usize; + if let Ok(mut c) = ca.lock() { c.num_bands = nb; } + ap(); + for (i, strip) in strips_c.iter().enumerate() { strip.set_active(i < nb); } + }); + } + + // Sliders IN/OUT/LIMITER + let sl_in = gtk4::Scale::with_range(Orientation::Horizontal, -12.0, 12.0, 0.5); + sl_in.set_value(cfg_arc.lock().unwrap().input_gain_db as f64); + sl_in.set_width_request(80); + { let ca = cfg_arc.clone(); let ap = apply.clone(); + sl_in.connect_value_changed(move |s| { if let Ok(mut c) = ca.lock() { c.input_gain_db = s.value() as f32; } ap(); }); } + + let sl_out = gtk4::Scale::with_range(Orientation::Horizontal, -12.0, 12.0, 0.5); + sl_out.set_value(cfg_arc.lock().unwrap().output_gain_db as f64); + sl_out.set_width_request(80); + { let ca = cfg_arc.clone(); let ap = apply.clone(); + sl_out.connect_value_changed(move |s| { if let Ok(mut c) = ca.lock() { c.output_gain_db = s.value() as f32; } ap(); }); } + + let sl_lim = gtk4::Scale::with_range(Orientation::Horizontal, -6.0, 0.0, 0.1); + sl_lim.set_value(cfg_arc.lock().unwrap().limiter_threshold as f64); + sl_lim.set_width_request(80); + { let ca = cfg_arc.clone(); let ap = apply.clone(); + sl_lim.connect_value_changed(move |s| { if let Ok(mut c) = ca.lock() { c.limiter_threshold = s.value() as f32; } ap(); }); } + + // Panel izquierdo: bandas + IN gain + let side_l = GtkBox::builder().orientation(Orientation::Vertical).spacing(6) + .css_classes(["side-panel"]).vexpand(true).build(); + side_l.append(&Label::new(Some("BANDS"))); + side_l.append(&sp_bands); + side_l.append(>k4::Separator::new(Orientation::Horizontal)); + side_l.append(&Label::new(Some("IN GAIN"))); + side_l.append(&sl_in); + + // Panel derecho: OUT gain + limitador + let side_r = GtkBox::builder().orientation(Orientation::Vertical).spacing(6) + .css_classes(["side-panel"]).vexpand(true).build(); + side_r.append(&Label::new(Some("OUT GAIN"))); + side_r.append(&sl_out); + side_r.append(>k4::Separator::new(Orientation::Horizontal)); + side_r.append(&Label::new(Some("LIMITER"))); + side_r.append(&sl_lim); + + let center = GtkBox::builder() + .orientation(Orientation::Horizontal).spacing(4).vexpand(true).build(); + center.append(&side_l); + center.append(&bands_box); + center.append(&side_r); + root.append(¢er); + + // ── LOAD preset ────────────────────────────────────────────────────────── + { + let ca = cfg_arc.clone(); + let ap = apply.clone(); + let strips_c = strips.clone(); + let sp_c = sp_bands.clone(); + let pr = presets_list.clone(); + let cb = combo_preset.clone(); + btn_load.connect_clicked(move |_| { + let name = match cb.active_text() { Some(n) => n.to_string(), None => return }; + let found = pr.borrow().iter().find(|p| p.name == name).cloned(); + if let Some(preset) = found { + // Aplicar preset + guardar nombre como último usado + { if let Ok(mut c) = ca.lock() { + preset.apply(&mut c); + c.last_preset = name.clone(); + } } + // apply() clona la config internamente → sin lock al actualizar DSP + ap(); + // Extraer valores con el lock, luego liberar ANTES de actualizar GTK. + // Crítico: set_active/set_value en CheckButton y SpinButton emiten señales + // síncronas que intentan bloquear ca de nuevo → deadlock si lo mantenemos. + let (bands_snap, nb_snap) = match ca.lock() { + Ok(c) => (c.bands.clone(), c.num_bands), + Err(_) => return, + }; // lock liberado aquí + for (b, strip) in strips_c.iter().enumerate() { + let bd = &bands_snap[b]; + strip.threshold.set_value(bd.threshold_db); + strip.ratio .set_value(bd.ratio); + strip.attack .set_value(bd.attack_ms); + strip.release .set_value(bd.release_ms); + strip.makeup .set_value(bd.makeup_db); + strip.clip_lvl .set_value(bd.clip_level_db); + strip.clip_en .set_active(bd.clip_enable); + strip.enabled .set_active(bd.enabled); + strip.set_active(b < nb_snap); + } + sp_c.set_value(nb_snap as f64); // dispara callback; ca ya está libre + } + }); + } + + // ── SAVE preset ─────────────────────────────────────────────────────────── + { + let ca = cfg_arc.clone(); + let pr = presets_list.clone(); + let cb2 = combo_preset.clone(); + let win_c = win.clone(); + btn_save.connect_clicked(move |_| { + let dialog = gtk4::Window::builder() + .transient_for(&win_c) + .modal(true) + .title(i18n::tr("preset.save_dialog")) + .default_width(300) + .build(); + let vbox = GtkBox::new(Orientation::Vertical, 10); + vbox.set_margin_start(16); vbox.set_margin_end(16); + vbox.set_margin_top(14); vbox.set_margin_bottom(14); + vbox.append(&Label::builder().label(i18n::tr("preset.name_label")).halign(gtk4::Align::Start).build()); + let entry = gtk4::Entry::builder().text(i18n::tr("preset.default_name")).hexpand(true).build(); + vbox.append(&entry); + let btn_row2 = GtkBox::new(Orientation::Horizontal, 6); + btn_row2.set_halign(gtk4::Align::End); + let btn_cancel = Button::with_label(i18n::tr("btn.cancel")); + let btn_ok = Button::with_label(i18n::tr("btn.save")); + btn_row2.append(&btn_cancel); + btn_row2.append(&btn_ok); + vbox.append(&btn_row2); + dialog.set_child(Some(&vbox)); + + let d1 = dialog.clone(); + btn_cancel.connect_clicked(move |_| d1.destroy()); + + let d2 = dialog.clone(); + let ca2 = ca.clone(); + let pr2 = pr.clone(); + let cb3 = cb2.clone(); + btn_ok.connect_clicked(move |_| { + let name = entry.text().to_string().trim().to_string(); + if name.is_empty() { return; } + if let Ok(c) = ca2.lock() { + let preset = processor::presets::Preset::from_config(&name, &c); + if let Err(e) = preset.save() { + log::error!("Error guardando preset: {}", e); + } + let exists = pr2.borrow().iter().any(|p| p.name == name); + if !exists { cb3.append(Some(&name), &name); } + pr2.borrow_mut().retain(|p| p.name != name); + pr2.borrow_mut().push(preset); + cb3.set_active_id(Some(&name)); + } + d2.destroy(); + }); + dialog.present(); + }); + } + + // Timer 30 fps: sincroniza VU metros desde DspProcessor y refresca DrawingAreas. + // Se detiene automáticamente cuando se cierra la ventana (weak reference). + let strips_t = strips.clone(); + let ma_t = meter_arcs.clone(); + let state_t = state.clone(); + let win_weak = win.downgrade(); + glib::timeout_add_local(std::time::Duration::from_millis(33), move || { + // Parar si la ventana fue destruida + if win_weak.upgrade().is_none() { return glib::ControlFlow::Break; } + // Leer metros globales escritos por el pad probe del pipeline activo (sin lock) + for b in 0..6 { + ma_t[b].0.store(processor::display_level(b)); + ma_t[b].1.store(processor::display_gr(b)); + } + for s in &strips_t { s.da_vu.queue_draw(); s.da_gr.queue_draw(); } + glib::ControlFlow::Continue + }); + + win.set_child(Some(&root)); + win.present(); +} + +// ─── Helpers de tandas comerciales próximas ─────────────────────────────────── + +/// Escribe la marca de "tanda reproducida manualmente" al archivo played_breaks. +fn mark_break_played(hour: u32, minute: u32, home: &std::path::Path) { + let today = chrono::Local::now().format("%Y%m%d").to_string(); + let key = format!("{} {:02}:{:02}", today, hour, minute); + let path = home.join(".gradio/data/tmp/played_breaks"); + let mut lines: Vec = std::fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter(|l| l.starts_with(&today)) + .map(String::from) + .collect(); + if !lines.contains(&key) { + lines.push(key); + } + if let Ok(mut f) = std::fs::OpenOptions::new() + .write(true).create(true).truncate(true).open(&path) + { + use std::io::Write; + for line in &lines { + let _ = writeln!(f, "{}", line); + } + } +} + +/// Intercambia dos líneas en un archivo .com de comerciales. +fn swap_com_file_lines(home: &std::path::Path, hour: u32, minute: u32, a: usize, b: usize) { + let path = home.join(format!(".gradio/data/comerciales/{}/{}.com", hour, minute)); + let Ok(content) = std::fs::read_to_string(&path) else { return }; + let mut lines: Vec = content.lines().map(String::from).collect(); + if a < lines.len() && b < lines.len() { + lines.swap(a, b); + let _ = std::fs::write(&path, lines.join("\n") + "\n"); + } +} + +/// Elimina una línea de un archivo .com de comerciales. +fn delete_com_file_line(home: &std::path::Path, hour: u32, minute: u32, idx: usize) { + let path = home.join(format!(".gradio/data/comerciales/{}/{}.com", hour, minute)); + let Ok(content) = std::fs::read_to_string(&path) else { return }; + let mut lines: Vec = content.lines().map(String::from).collect(); + if idx < lines.len() { + lines.remove(idx); + let _ = std::fs::write(&path, lines.join("\n") + "\n"); + } +} + +/// Verifica si un día (dow 1=Lun..7=Dom) está en la máscara (versión con dow explícito). +fn valid_day_mask_with_dow(dow: u32, mask: &str) -> bool { + if mask == "0" || mask.is_empty() { return true; } + mask.chars().any(|c| c.to_digit(10) == Some(dow)) +} + +/// Verifica si la fecha actual está en el rango start_date..end_date. +fn in_date_range_ui(now: chrono::NaiveDate, start: &str, end: &str) -> bool { + let start_ok = start == "0" || start.is_empty() || + chrono::NaiveDate::parse_from_str(start, "%Y%m%d") + .map(|d| now >= d).unwrap_or(true); + let end_ok = end == "0" || end.is_empty() || + chrono::NaiveDate::parse_from_str(end, "%Y%m%d") + .map(|d| now <= d).unwrap_or(true); + start_ok && end_ok +} + +/// Estructura para una tanda próxima. +struct UpcomingBreak { + hour: u32, + minute: u32, + // (path, display_name, duration_secs, file_line_idx) + // file_line_idx: línea real en el .com (puede diferir del índice visual por ítems filtrados) + items: Vec<(String, String, f64, usize)>, + total_dur: f64, +} + +/// Escanea los próximos minutos del horario para encontrar hasta `n` tandas con comerciales. +fn scan_upcoming_breaks(home: &std::path::Path, n: usize) -> Vec { + use chrono::{Local, Timelike, Datelike}; + let now = Local::now(); + let today_str = now.format("%Y%m%d").to_string(); + let today_date = now.date_naive(); + + // Leer played_breaks de hoy + let played_path = home.join(".gradio/data/tmp/played_breaks"); + let played: std::collections::HashSet = std::fs::read_to_string(&played_path) + .unwrap_or_default() + .lines() + .filter(|l| l.starts_with(&today_str)) + .map(|l| l.trim().to_string()) + .collect(); + + let _ = today_date; // used in loop below via cur_date + + let mut breaks = Vec::new(); + let mut current = now + chrono::Duration::minutes(1); + let max_minutes = 4 * 60; + + for _ in 0..max_minutes { + if breaks.len() >= n { break; } + let h = current.hour(); + let m = current.minute(); + let dow = current.weekday().number_from_monday(); + let cur_date = current.date_naive(); + + let com_file = home.join(format!(".gradio/data/comerciales/{}/{}.com", h, m)); + if com_file.exists() { + let key = format!("{} {:02}:{:02}", today_str, h, m); + if played.contains(&key) { + current = current + chrono::Duration::minutes(1); + continue; + } + + if let Ok(content) = std::fs::read_to_string(&com_file) { + let mut items: Vec<(String, String, f64, usize)> = Vec::new(); + for (file_line, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { continue; } + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() != 4 { continue; } + let path = parts[0].trim(); + let days_mask = parts[1].trim(); + let start_date = parts[2].trim(); + let end_date = parts[3].trim(); + + if !valid_day_mask_with_dow(dow, days_mask) { continue; } + if !in_date_range_ui(cur_date, start_date, end_date) { continue; } + + let display = display_name_for_queue_line(path); + let dur = duration_for_queue_line(path); + items.push((path.to_string(), display, dur, file_line)); + } + + if !items.is_empty() { + let total_dur: f64 = items.iter().map(|(_, _, d, _)| d).sum(); + breaks.push(UpcomingBreak { hour: h, minute: m, items, total_dur }); + } + } + } + + current = current + chrono::Duration::minutes(1); + } + + breaks +} + +/// Construye una tarjeta visual para una tanda de comerciales próxima. +fn build_tanda_card( + home: &std::path::PathBuf, + hora: u32, + minuto: u32, + items: &[(String, String, f64, usize)], // (path, name, dur, file_line) + total_dur: f64, + color_idx: usize, + state: SharedState, + tandas_box: &GtkBox, // para rebuild inmediato tras ▲▼✕ +) -> Frame { + let card = Frame::new(None::<&str>); + card.add_css_class("tanda-card"); + + let vbox = GtkBox::new(Orientation::Vertical, 2); + vbox.set_margin_start(6); + vbox.set_margin_end(6); + vbox.set_margin_top(4); + vbox.set_margin_bottom(4); + // Color de fondo vía clase CSS — background-image no es sobreescrito por el tema + vbox.add_css_class(&format!("tanda-bg-{}", color_idx % 4)); + + // ── Header: hora | duración total | botón play ──────────────────────────── + let header = GtkBox::new(Orientation::Horizontal, 8); + header.set_margin_bottom(2); + + let lbl_hora = Label::new(Some(&format!("{:02}:{:02}", hora, minuto))); + lbl_hora.add_css_class("tanda-header"); + lbl_hora.set_xalign(0.0); + + let total_str = secs_to_display(total_dur); + let lbl_total = Label::new(Some(&total_str)); + lbl_total.set_hexpand(true); + lbl_total.set_xalign(0.5); + + let btn_play = Button::with_label("\u{25b6}"); + btn_play.set_tooltip_text(Some(&format!("Reproducir tanda {:02}:{:02}", hora, minuto))); + + { + let home_c = home.clone(); + let items_c: Vec = items.iter().map(|(p, _, _, _)| p.clone()).collect(); + btn_play.connect_clicked(move |_| { + // 1. Cargar la tanda en la cola activa (reemplaza) + let comlist = home_c.join(".gradio/data/tmp/comercialeslist4"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .write(true).create(true).truncate(true).open(&comlist) + { + use std::io::Write; + for path in &items_c { + let _ = writeln!(f, "{}", path); + } + } + // 2. Marcar como reproducida para que el scheduler la salte + mark_break_played(hora, minuto, &home_c); + // 3. Señal IPC: el timer del player inicia reproducción inmediata + // Si hay tema en curso → fadeout y reproduce; si está detenido → reproduce + let _ = std::fs::write( + home_c.join(".gradio/data/tmp/cmd_play_tanda"), + "1", + ); + }); + } + + header.append(&lbl_hora); + header.append(&lbl_total); + header.append(&btn_play); + vbox.append(&header); + vbox.append(>k4::Separator::new(Orientation::Horizontal)); + + // ── Lista de items de la tanda ──────────────────────────────────────────── + // file_lines: índices reales en el .com para swap/delete correcto con ítems filtrados + let file_lines: Vec = items.iter().map(|(_, _, _, fl)| *fl).collect(); + let n = items.len(); + for (i, (path, name, dur, _file_line)) in items.iter().enumerate() { + let row = GtkBox::new(Orientation::Horizontal, 4); + + let lbl_name = Label::new(Some(name)); + lbl_name.set_hexpand(true); + lbl_name.set_halign(gtk4::Align::Start); + lbl_name.set_ellipsize(gtk4::pango::EllipsizeMode::End); + lbl_name.set_tooltip_text(Some(path)); + + let dur_str = if *dur > 0.0 { secs_to_display(*dur) } else { "\u{2014}".to_string() }; + let lbl_dur = Label::new(Some(&dur_str)); + lbl_dur.add_css_class("queue-dur-label"); + + let btn_up = Button::with_label("\u{25b2}"); + let btn_down = Button::with_label("\u{25bc}"); + let btn_del = Button::with_label("\u{2715}"); + btn_up.add_css_class("queue-action-btn"); + btn_down.add_css_class("queue-action-btn"); + btn_del.add_css_class("queue-action-btn"); + btn_up.set_sensitive(i > 0); + btn_down.set_sensitive(i + 1 < n); + + { + let h = home.clone(); let tb = tandas_box.clone(); + let hh = home.clone(); let st = state.clone(); + let hr = hora; let mi = minuto; + let fl_cur = file_lines[i]; + let fl_prev = if i > 0 { file_lines[i - 1] } else { 0 }; + btn_up.connect_clicked(move |_| { + swap_com_file_lines(&h, hr, mi, fl_cur, fl_prev); + rebuild_tandas_box(&tb, &hh, st.clone()); + }); + } + { + let h = home.clone(); let tb = tandas_box.clone(); + let hh = home.clone(); let st = state.clone(); + let hr = hora; let mi = minuto; + let fl_cur = file_lines[i]; + let fl_next = if i + 1 < n { file_lines[i + 1] } else { 0 }; + btn_down.connect_clicked(move |_| { + swap_com_file_lines(&h, hr, mi, fl_cur, fl_next); + rebuild_tandas_box(&tb, &hh, st.clone()); + }); + } + { + let h = home.clone(); let tb = tandas_box.clone(); + let hh = home.clone(); let st = state.clone(); + let hr = hora; let mi = minuto; + let fl = file_lines[i]; + btn_del.connect_clicked(move |_| { + delete_com_file_line(&h, hr, mi, fl); + rebuild_tandas_box(&tb, &hh, st.clone()); + }); + } + + row.append(&lbl_name); + row.append(&lbl_dur); + row.append(&btn_up); + row.append(&btn_down); + row.append(&btn_del); + vbox.append(&row); + } + + card.set_child(Some(&vbox)); + card +} + +/// Construye el panel de cola activa (comercialeslist4) con el mismo estilo de tarjeta. +fn build_active_queue_card(home: &std::path::PathBuf, tandas_box: &GtkBox, state: SharedState) -> Option { + let path = home.join(".gradio/data/tmp/comercialeslist4"); + let content = std::fs::read_to_string(&path).unwrap_or_default(); + let lines: Vec = content.lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + if lines.is_empty() { return None; } + + let card = Frame::new(None::<&str>); + card.add_css_class("tanda-card"); + let vbox = GtkBox::new(Orientation::Vertical, 2); + vbox.set_margin_start(6); vbox.set_margin_end(6); + vbox.set_margin_top(4); vbox.set_margin_bottom(4); + // Fondo oscuro rojizo para distinguir la cola activa de las tandas futuras + vbox.add_css_class("tanda-bg-active"); + + // Header + let header = GtkBox::new(Orientation::Horizontal, 8); + header.set_margin_bottom(2); + let lbl_titulo = Label::new(Some(i18n::tr("cola.titulo"))); + lbl_titulo.add_css_class("tanda-header"); + lbl_titulo.set_xalign(0.0); + let total_dur: f64 = lines.iter().map(|l| duration_for_queue_line(l)).sum(); + let total_str = secs_to_display(total_dur); + let lbl_total = Label::new(Some(&total_str)); + lbl_total.set_hexpand(true); + lbl_total.set_xalign(0.5); + let btn_vaciar = Button::with_label(i18n::tr("cola.vaciar")); + btn_vaciar.set_tooltip_text(Some(i18n::tr("tip.empty_queue"))); + { + let p = path.clone(); let tb = tandas_box.clone(); + let hh = home.clone(); let st = state.clone(); + btn_vaciar.connect_clicked(move |_| { + let _ = std::fs::write(&p, ""); + rebuild_tandas_box(&tb, &hh, st.clone()); + }); + } + header.append(&lbl_titulo); + header.append(&lbl_total); + header.append(&btn_vaciar); + vbox.append(&header); + vbox.append(>k4::Separator::new(Orientation::Horizontal)); + + // Ítems + let n = lines.len(); + for (i, line) in lines.iter().enumerate() { + let row = GtkBox::new(Orientation::Horizontal, 4); + let name = display_name_for_queue_line(line); + let lbl_name = Label::new(Some(&name)); + lbl_name.set_hexpand(true); + lbl_name.set_halign(gtk4::Align::Start); + lbl_name.set_ellipsize(gtk4::pango::EllipsizeMode::End); + lbl_name.set_tooltip_text(Some(line)); + let dur = duration_for_queue_line(line); + let dur_str = if dur > 0.0 { secs_to_display(dur) } else { "—".to_string() }; + let lbl_dur = Label::new(Some(&dur_str)); + lbl_dur.add_css_class("queue-dur-label"); + let btn_del = Button::with_label("\u{2715}"); + btn_del.add_css_class("queue-action-btn"); + { + let fp = path.clone(); let idx = i; let ntot = n; + let tb = tandas_box.clone(); let hh = home.clone(); let st = state.clone(); + btn_del.connect_clicked(move |_| { + if let Ok(c) = std::fs::read_to_string(&fp) { + let mut lns: Vec<&str> = c.lines().filter(|l| !l.trim().is_empty()).collect(); + if idx < lns.len() { lns.remove(idx); } + let _ = std::fs::write(&fp, lns.join("\n") + if lns.is_empty() { "" } else { "\n" }); + } + rebuild_tandas_box(&tb, &hh, st.clone()); + let _ = ntot; // silence unused warning + }); + } + row.append(&lbl_name); + row.append(&lbl_dur); + row.append(&btn_del); + vbox.append(&row); + } + + card.set_child(Some(&vbox)); + Some(card) +} + +/// Reconstruye el contenido del box de tandas próximas. +fn rebuild_tandas_box( + tandas_box: &GtkBox, + home: &std::path::PathBuf, + state: SharedState, +) { + while let Some(child) = tandas_box.first_child() { + tandas_box.remove(&child); + } + + // Cola activa (comercialeslist4) arriba, con mismo formato de tarjeta + if let Some(card) = build_active_queue_card(home, tandas_box, state.clone()) { + tandas_box.append(&card); + } + + let breaks = scan_upcoming_breaks(home, 4); + if breaks.is_empty() { + let lbl = Label::new(Some(i18n::tr("cola.no_tandas"))); + lbl.set_margin_top(8); + lbl.set_xalign(0.5); + tandas_box.append(&lbl); + return; + } + + for (idx, br) in breaks.iter().enumerate() { + let card = build_tanda_card(home, br.hour, br.minute, &br.items, br.total_dur, + idx, state.clone(), tandas_box); + tandas_box.append(&card); + } +} + +// ─── GStreamer helpers ──────────────────────────────────────────────────────── + + +/// Configuración centralizada: ~/.gradio/data/tmp/gradio.config +/// Línea 1: tarjeta audio principal +/// Línea 2: tarjeta audio CUE +/// Línea 3: nombre de la radio +/// Línea 4: tiempo de fundido (segundos) +#[derive(Clone)] +struct GradioConfig { + main_dev: Option, + cue_dev: Option, + station_name: String, + crossfade_secs: f64, + pisador_enabled: bool, // true = tocar pisador + pisador_dir: String, // carpeta de pisadores + pisador_every: u32, // cada cuántos temas (1 = todos) + pisador_exclude: Vec,// carpetas donde NO se toca pisador + silence_secs: f64, // segundos de silencio para avanzar al siguiente (0 = desact.) + carpetas_nacionales: Vec,// carpetas de música nacional (para reportes) + carpetas_intercultural:Vec,// carpetas de interculturalidad (para reportes) + // ── gr-client servidor ──────────────────────────────────────────────────── + client_port: u16, // puerto TCP del servidor gr-client (default 7777) + client_token: String, // token de autenticación (vacío = sin auth) + // ── relay internet ──────────────────────────────────────────────────────── + relay_habilitado: bool, // true = conectar al servidor relay configurado (GRADIO_RELAY_URL) + relay_id: String, // ID de 8 dígitos para el relay + // ── playlist-refill ─────────────────────────────────────────────────────── + no_repeat_days: u32, // días hacia atrás para el control de no-repetición (default 3) + // ── i18n ────────────────────────────────────────────────────────────────── + locale: Option, // "es"|"en"|"pt" o None (autodetectar) + // ── IA (OpenCode) ───────────────────────────────────────────────────────── + ia_habilitada: bool, // true = mostrar botón IA y permitir lanzar OpenCode + // ── Fundido ─────────────────────────────────────────────────────────────── + fundido_exclude: Vec, // carpetas cuyos archivos arrancan/terminan sin crossfade + // ── Locutor Automático (locución IA, ver Dropbox/LocutorIA) ─────────────── + // Solo una bandera de intención: la generación de audio la hacen agentes + // externos (claude/opencode) que la leen desde tmp/locutor_auto, no este proceso. + locutor_habilitado: bool, + // ── Players (paneles de reproducción visibles) ──────────────────────────── + // 3 = Deck A + Deck B + barra de comercial (actual/default) + // 2 = un solo deck fusionado + barra de comercial + // 1 = un solo panel fusionado (deck activo o comercial, lo que suene) + players: u8, +} + +fn config_path() -> PathBuf { + dirs::home_dir().unwrap().join(".gradio/data/tmp/gradio.config") +} + +/// Lee la carpeta de pisadores específica para la hora y día actuales. +/// Devuelve cadena vacía si no hay carpeta configurada para esa hora. +fn pisador_dir_hora_actual() -> String { + use chrono::Datelike; + let now = Local::now(); + let hora = now.hour() as u8; + let dia = now.weekday().number_from_monday() as u8; + let path = dirs::home_dir().unwrap() + .join(".gradio/data/parrilla") + .join(dia.to_string()) + .join(format!("{}-{}.pisador", hora, hora + 1)); + fs::read_to_string(&path) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Abre opencode en el terminal del sistema como fallback cuando opencode-terminal +/// no está disponible o no puede ejecutarse (ej. incompatibilidad de glibc). +fn launch_opencode_en_terminal(bin: &PathBuf, dir: &PathBuf) { + let bin_str = bin.to_string_lossy().into_owned(); + let dir_str = dir.to_string_lossy().into_owned(); + // x-terminal-emulator (gnome-terminal.wrapper) traduce "-e arg1 arg2" → "-- arg1 arg2" + // No usar "--" directamente: el wrapper lo descarta sin pasarlo a gnome-terminal. + let _ = std::process::Command::new("x-terminal-emulator") + .args(["-e", &bin_str, &dir_str]) + .spawn() + .or_else(|_| std::process::Command::new("gnome-terminal") + .args(["--", &bin_str, &dir_str]) + .spawn()) + .or_else(|_| std::process::Command::new("xterm") + .args(["-e", &bin_str, &dir_str]) + .spawn()); +} + +fn read_gradio_config() -> GradioConfig { + let path = config_path(); + let text = fs::read_to_string(&path).unwrap_or_default(); + let mut lines = text.lines(); + let main_dev = lines.next().map(|l| l.trim().to_string()).filter(|s| !s.is_empty()); + let cue_dev = lines.next().map(|l| l.trim().to_string()).filter(|s| !s.is_empty()); + let station_name = lines.next().map(|l| l.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "G Radio".to_string()); + let crossfade_secs = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .unwrap_or(3.0); + // Línea 5: pisador_enabled (1 = sí, 0 = no) + let pisador_enabled = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .map(|v| v != 0) + .unwrap_or(false); + // Línea 6: carpeta de pisadores + let pisador_dir = lines.next() + .map(|l| l.trim().to_string()) + .unwrap_or_default(); + // Línea 7: cada cuántos temas (0/vacío = mismo que 1) + let pisador_every = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .map(|v| if v == 0 { 1 } else { v }) + .unwrap_or(1); + // Línea 8: carpetas excluidas separadas por ; entre comillas + // Formato: "/ruta/uno";"/ruta/dos con ; en nombre" + let pisador_exclude = lines.next() + .map(|l| parse_quoted_semicolon_list(l.trim())) + .unwrap_or_default(); + // Línea 9: segundos de silencio para avanzar (0 = desactivado) + let silence_secs = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .unwrap_or(5.0); + // Línea 10: carpetas de música nacional + let carpetas_nacionales = lines.next() + .map(|l| parse_quoted_semicolon_list(l.trim())) + .unwrap_or_default(); + // Línea 11: carpetas de interculturalidad + let carpetas_intercultural = lines.next() + .map(|l| parse_quoted_semicolon_list(l.trim())) + .unwrap_or_default(); + // Línea 12: puerto servidor gr-client (default 7777) + let client_port = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .unwrap_or(7777); + // Línea 13: token de autenticación gr-client (vacío = sin auth) + let client_token = lines.next() + .map(|l| l.trim().to_string()) + .unwrap_or_default(); + // Línea 14: relay habilitado (1 = sí, 0 = no) + let relay_habilitado = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .map(|v| v != 0) + .unwrap_or(false); + // Línea 15: ID de relay (8 dígitos) + let relay_id = lines.next() + .map(|l| l.trim().to_string()) + .unwrap_or_default(); + // Línea 16: días de lookback para no-repetición (default 3, mínimo 1) + let no_repeat_days = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .filter(|&v| v >= 1) + .unwrap_or(3); + // Línea 17: locale forzado de la UI ("es"/"en"/"pt" o vacío = autodetectar) + let locale = lines.next() + .map(|l| l.trim().to_string()) + .filter(|s| !s.is_empty()); + // Línea 18: IA habilitada (1 = sí, 0 = no) + let ia_habilitada = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .map(|v| v != 0) + .unwrap_or(false); + // Línea 19: carpetas que no se funden (crossfade) + let fundido_exclude = lines.next() + .map(|l| parse_quoted_semicolon_list(l.trim())) + .unwrap_or_default(); + // Línea 20: Locutor Automático habilitado (1 = sí, 0 = no) + let locutor_habilitado = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .map(|v| v != 0) + .unwrap_or(false); + // Línea 21: Players — cuántos paneles de reproducción se muestran (3/2/1) + let players = lines.next() + .and_then(|l| l.trim().parse::().ok()) + .filter(|v| matches!(v, 1 | 2 | 3)) + .unwrap_or(3); + GradioConfig { main_dev, cue_dev, station_name, crossfade_secs, + pisador_enabled, pisador_dir, pisador_every, pisador_exclude, + silence_secs, carpetas_nacionales, carpetas_intercultural, + client_port, client_token, + relay_habilitado, relay_id, no_repeat_days, + locale, ia_habilitada, fundido_exclude, locutor_habilitado, + players } +} + +/// Parsea una lista de rutas separadas por ; y entre comillas. +/// Ej: "/ruta/a";"/ruta/b con ; en nombre" → vec!["/ruta/a", "/ruta/b con ; en nombre"] +fn parse_quoted_semicolon_list(s: &str) -> Vec { + let mut result = Vec::new(); + let mut rest = s; + while !rest.is_empty() { + rest = rest.trim_start(); + if rest.starts_with('"') { + rest = &rest[1..]; + if let Some(end) = rest.find('"') { + result.push(rest[..end].to_string()); + rest = &rest[end + 1..]; + rest = rest.trim_start_matches(';'); + } else { + // comilla sin cerrar: tomar todo + result.push(rest.to_string()); + break; + } + } else if let Some(end) = rest.find(';') { + let item = rest[..end].trim().to_string(); + if !item.is_empty() { result.push(item); } + rest = &rest[end + 1..]; + } else { + let item = rest.trim().to_string(); + if !item.is_empty() { result.push(item); } + break; + } + } + result +} + +fn fmt_path_list(v: &[String]) -> String { + v.iter().map(|p| format!("\"{}\"", p)).collect::>().join(";") +} + +fn write_gradio_config(cfg: &GradioConfig) { + let text = format!( + "{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +", + cfg.main_dev.as_deref().unwrap_or(""), + cfg.cue_dev.as_deref().unwrap_or(""), + cfg.station_name, + cfg.crossfade_secs, + if cfg.pisador_enabled { 1 } else { 0 }, + cfg.pisador_dir, + cfg.pisador_every, + fmt_path_list(&cfg.pisador_exclude), + cfg.silence_secs, + fmt_path_list(&cfg.carpetas_nacionales), + fmt_path_list(&cfg.carpetas_intercultural), + cfg.client_port, + cfg.client_token, + if cfg.relay_habilitado { 1 } else { 0 }, + cfg.relay_id, + cfg.no_repeat_days, + cfg.locale.as_deref().unwrap_or(""), + if cfg.ia_habilitada { 1 } else { 0 }, + fmt_path_list(&cfg.fundido_exclude), + if cfg.locutor_habilitado { 1 } else { 0 }, + cfg.players, + ); + if let Err(e) = fs::write(config_path(), &text) { + eprintln!("Error guardando gradio.config: {}", e); + } + // Espejo simple en tmp/ para que agentes externos (claude/opencode que + // generan la locución IA, ver Dropbox/LocutorIA) lean la bandera sin + // tener que parsear el formato posicional de gradio.config. + let locutor_flag_path = dirs::home_dir().unwrap().join(".gradio/data/tmp/locutor_auto"); + let _ = fs::write(&locutor_flag_path, if cfg.locutor_habilitado { "1" } else { "0" }); +} + +// Compatibilidad: funciones auxiliares que usan GradioConfig +fn read_device_config() -> (Option, Option) { + let cfg = read_gradio_config(); + (cfg.main_dev, cfg.cue_dev) +} + + +/// Conecta un bus watch al pipeline para leer mensajes `level` del elemento +/// GStreamer y actualizar vu_level_l/r en el AppState. +/// Se llama cada vez que se crea un pipeline de deck A o B. +fn attach_level_watch(pipeline: &gst::Pipeline, state: SharedState) { + use gst::prelude::ElementExt; + let bus = match pipeline.bus() { + Some(b) => b, + None => return, + }; + // add_watch corre en el main loop de GLib — seguro para GTK + // IMPORTANTE: add_watch_local devuelve un BusWatchGuard (RAII). Si se descarta + // con `let _ = ...`, su Drop llama remove_watch inmediatamente y el watch nunca + // recibe mensajes. Se usa mem::forget para mantenerlo vivo; el pipeline lo limpia + // al transicionar a NULL y soltarse del AppState. + let watch_result = bus.add_watch_local(move |_, msg| { + if let gst::MessageView::Element(elem) = msg.view() { + if let Some(structure) = elem.structure() { + if structure.name() == "level" { + // Mapeo dB → display: -60dB=0%, -3dB≈100% + let to_display = |db: f64| -> f64 { + if db <= -60.0 { return 0.0; } + let min_db = -50.0_f64; + let max_db = -3.0_f64; + ((db - min_db) / (max_db - min_db)).clamp(0.0, 1.0) + }; + // En GStreamer 1.20+ el campo "rms" es un GValueArray de f64. + // Leer directamente por API evita depender del formato del string. + let chs: Vec = if let Ok(arr) = structure.get::("rms") { + arr.iter() + .filter_map(|v| v.get::().ok()) + .collect() + } else { + // Fallback: parsear el string serializado (GStreamer < 1.20) + let struct_str = structure.to_string(); + parse_level_field(&struct_str, "rms") + }; + if !chs.is_empty() { + if let Ok(mut st) = state.try_lock() { + let lev_l = to_display(chs[0]); + let lev_r = to_display(*chs.get(1).unwrap_or(&chs[0])); + if st.vu_active { + st.vu_level_l = lev_l; + st.vu_level_r = lev_r; + } + // Raw en dB (para detector de silencio — umbral real, no display) + st.vu_level_l_raw = chs[0]; + st.vu_level_r_raw = *chs.get(1).unwrap_or(&chs[0]); + st.vu_last_level_msg = std::time::Instant::now(); + } + } + } + } + } + glib::ControlFlow::Continue + }); + if let Ok(guard) = watch_result { + std::mem::forget(guard); + } +} + +/// Variante de attach_level_watch para pipelines de comerciales/eventos. +/// Usa enable_sync_message_emission + connect_sync_message en lugar de +/// add_watch_local, de modo que los mensajes EOS/Error siguen disponibles +/// en la cola async del bus para que wait_pipeline_eos los pueda leer con +/// timed_pop. Con add_watch_local (usado por los decks de música) el watch +/// consume los mensajes del bus y wait_pipeline_eos nunca ve el EOS. +fn attach_level_watch_comm(pipeline: &gst::Pipeline, state: SharedState) { + let bus = match pipeline.bus() { + Some(b) => b, + None => return, + }; + bus.enable_sync_message_emission(); + bus.connect_sync_message(None, move |_bus, msg| { + if let gst::MessageView::Element(elem) = msg.view() { + if let Some(structure) = elem.structure() { + if structure.name() == "level" { + let to_display = |db: f64| -> f64 { + if db <= -60.0 { return 0.0; } + let min_db = -50.0_f64; + let max_db = -3.0_f64; + ((db - min_db) / (max_db - min_db)).clamp(0.0, 1.0) + }; + let chs: Vec = if let Ok(arr) = structure.get::("rms") { + arr.iter() + .filter_map(|v| v.get::().ok()) + .collect() + } else { + let struct_str = structure.to_string(); + parse_level_field(&struct_str, "rms") + }; + if !chs.is_empty() { + if let Ok(mut st) = state.try_lock() { + let lev_l = to_display(chs[0]); + let lev_r = to_display(*chs.get(1).unwrap_or(&chs[0])); + if st.vu_active { + st.vu_level_l = lev_l; + st.vu_level_r = lev_r; + } + st.vu_level_l_raw = chs[0]; + st.vu_level_r_raw = *chs.get(1).unwrap_or(&chs[0]); + st.vu_last_level_msg = std::time::Instant::now(); + } + } + } + } + } + }); +} + +fn parse_level_field(structure_str: &str, field: &str) -> Vec { + // Buscar "field=(double)" o "field=(double){" + let marker = format!("{}=(double)", field); + let start = match structure_str.find(&marker) { + Some(p) => p + marker.len(), + None => return vec![], + }; + let rest = &structure_str[start..]; + // Extraer hasta el siguiente campo (coma fuera de llaves) o fin de string + let mut depth = 0usize; + let mut end = rest.len(); + for (i, c) in rest.char_indices() { + match c { + '{' => depth += 1, + '}' => { + if depth == 0 { end = i; break; } + depth -= 1; + if depth == 0 { end = i + 1; break; } + } + ',' if depth == 0 => { end = i; break; } + _ => {} + } + } + let segment = &rest[..end]; + // Parsear todos los números flotantes (incluye negativos) + let mut result = Vec::new(); + let mut i = 0usize; + let bytes = segment.as_bytes(); + while i < bytes.len() { + // Buscar inicio de número: dígito o '-' seguido de dígito + if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i+1].is_ascii_digit() { + let start_n = i; + i += 1; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { i += 1; } + if let Ok(v) = segment[start_n..i].parse::() { result.push(v); } + } else if bytes[i].is_ascii_digit() { + let start_n = i; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { i += 1; } + if let Ok(v) = segment[start_n..i].parse::() { result.push(v); } + } else { + i += 1; + } + } + result +} + +fn build_pipeline(uri: &str, volume: f64, dsp: Option) -> Result { + let pipeline = gst::Pipeline::new(); + let src = gst::ElementFactory::make("uridecodebin") + .property("uri", uri) + .build()?; + let audioconvert = gst::ElementFactory::make("audioconvert").build()?; + let audioresample = gst::ElementFactory::make("audioresample").build()?; + let volume_elem = gst::ElementFactory::make("volume") + .property("volume", volume) + .build()?; + // Sonda de nivel para VU meter — intervalo 100ms, sin peak ni rms falsos + let level_elem = gst::ElementFactory::make("level") + .property("interval", 50_000_000u64) // 50ms en nanosegundos + .property("peak-ttl", 0u64) + .property("post-messages", true) + .build() + .unwrap_or_else(|_| gst::ElementFactory::make("identity").build().unwrap()); + let (main_dev, _) = read_device_config(); + + // Cada pipeline recibe su PROPIO DspProcessor con estado limpio. + // Compartir el mismo Arc causaba chisporoteo al inicio de cada audio: los registros + // de retardo del crossover (biquad x1/x2/y1/y2) contenían muestras del audio anterior + // y contaminaban los primeros frames del nuevo hasta que los filtros convergían. + // La config se lee de disco (JSON pequeño) para evitar contención de mutex con el + // timer de la ventana de configuración (33 ms) que también bloquea el master Arc. + let dsp = dsp.or_else(|| { + let cfg = processor::ProcessorConfig::cargar(); + // Siempre crear probe; process_block() retorna de inmediato si cfg.enabled=false. + // Así el botón ON/OFF funciona sin reconstruir la pipeline. + Some(Arc::new(Mutex::new(processor::DspProcessor::new(cfg)))) + }); + + if let Some(dsp_arc) = dsp { + // ── Pipeline con procesador DSP via pad probe (in-place) ───────────── + let audio_caps = gstreamer_audio::AudioCapsBuilder::new() + .format(gstreamer_audio::AudioFormat::F32le) + .rate(48000i32) + .channels(2i32) + .build(); + + let caps_filter = gst::ElementFactory::make("capsfilter") + .property("caps", &audio_caps) + .build()?; + + // Post-DSP: convierte formato Y tasa de muestreo al nativo del sink. + // wasapi2sink (IAudioClient3) exige exactamente su mix rate; sin audioresample_out + // el capsfilter forzando 48000 Hz impide que wasapi2 negocie 44100 Hz o su tasa nativa. + // En Linux son no-ops porque pulsesink acepta F32LE@48k directamente. + let audioconvert_out = gst::ElementFactory::make("audioconvert").build()?; + let audioresample_out = gst::ElementFactory::make("audioresample").build()?; + + let audiosink = match main_dev { + Some(ref dev) => gst::ElementFactory::make("pulsesink") + .property("device", dev.as_str()) + .build() + .unwrap_or_else(|_| gst::ElementFactory::make("autoaudiosink").build().unwrap()), + None => gst::ElementFactory::make("autoaudiosink").build()?, + }; + + pipeline.add_many([&src, &audioconvert, &audioresample, &volume_elem, &level_elem, &caps_filter, &audioconvert_out, &audioresample_out, &audiosink])?; + gst::Element::link_many([&audioconvert, &audioresample, &volume_elem, &level_elem, &caps_filter, &audioconvert_out, &audioresample_out, &audiosink])?; + + // Pad probe: intercepta cada buffer, sincroniza config si cambió, aplica DSP in-place + let src_pad = caps_filter.static_pad("src").unwrap(); + let local_ver = std::sync::Arc::new(std::sync::atomic::AtomicU64::new( + PROC_CFG_VER.load(std::sync::atomic::Ordering::Relaxed) + )); + src_pad.add_probe(gst::PadProbeType::BUFFER, move |_pad, info| { + // Fast path: DSP apagado — pasar buffer sin tocar ni copiar. + if !PROC_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { + return gst::PadProbeReturn::Ok; + } + if let Some(gst::PadProbeData::Buffer(ref mut buf)) = info.data { + let buf_mut = buf.make_mut(); + if let Ok(mut map) = buf_mut.map_writable() { + let samples: &mut [f32] = unsafe { + std::slice::from_raw_parts_mut( + map.as_mut_ptr() as *mut f32, + map.len() / 4, + ) + }; + if let Ok(mut dsp) = dsp_arc.try_lock() { + // Sincronizar parámetros si el operador movió algún knob + let cur = PROC_CFG_VER.load(std::sync::atomic::Ordering::Relaxed); + if cur != local_ver.load(std::sync::atomic::Ordering::Relaxed) { + if let Ok(g) = proc_dsp_global().try_lock() { + if let Some(ref master) = *g { + if let Ok(m) = master.try_lock() { + dsp.update_config(m.cfg.clone()); + } + } + } + local_ver.store(cur, std::sync::atomic::Ordering::Relaxed); + } + // Forzar cfg.enabled=true: PROC_ENABLED ya pasó el gate de arriba. + // Evita que un cfg.enabled obsoleto (del momento de construcción del + // pipeline) haga que process_block retorne sin procesar. + dsp.cfg.enabled = true; + dsp.process_block(samples); + } + } + } + gst::PadProbeReturn::Ok + }); + + let audioconvert_weak = audioconvert.downgrade(); + src.connect_pad_added(move |_, pad| { + if let Some(ac) = audioconvert_weak.upgrade() { + let sink_pad = ac.static_pad("sink").unwrap(); + if !sink_pad.is_linked() { let _ = pad.link(&sink_pad); } + } + }); + + Ok(pipeline) + } else { + // ── Pipeline sin procesador (comportamiento actual) ────────────────── + let audiosink = match main_dev { + Some(ref dev) => gst::ElementFactory::make("pulsesink") + .property("device", dev.as_str()) + .build() + .unwrap_or_else(|_| gst::ElementFactory::make("autoaudiosink").build().unwrap()), + None => gst::ElementFactory::make("autoaudiosink").build()?, + }; + pipeline.add_many([&src, &audioconvert, &audioresample, &volume_elem, &level_elem, &audiosink])?; + gst::Element::link_many([&audioconvert, &audioresample, &volume_elem, &level_elem, &audiosink])?; + + let audioconvert_weak = audioconvert.downgrade(); + src.connect_pad_added(move |_, pad| { + if let Some(ac) = audioconvert_weak.upgrade() { + let sink_pad = ac.static_pad("sink").unwrap(); + if !sink_pad.is_linked() { + let _ = pad.link(&sink_pad); + } + } + }); + + Ok(pipeline) + } +} + + +/// Pipeline para preescucha (CUE) — usa la tarjeta secundaria configurada. +fn build_pipeline_cue(uri: &str) -> Result { + let pipeline = gst::Pipeline::new(); + let src = gst::ElementFactory::make("uridecodebin") + .property("uri", uri) + .build()?; + let audioconvert = gst::ElementFactory::make("audioconvert").build()?; + let audioresample = gst::ElementFactory::make("audioresample").build()?; + let volume_elem = gst::ElementFactory::make("volume") + .property("volume", 1.0f64) + .build()?; + let (_, cue_dev) = read_device_config(); + let audiosink = match cue_dev { + Some(ref dev) => { + gst::ElementFactory::make("pulsesink") + .property("device", dev.as_str()) + .build() + .unwrap_or_else(|_| gst::ElementFactory::make("autoaudiosink").build().unwrap()) + } + None => gst::ElementFactory::make("autoaudiosink").build()?, + }; + + pipeline.add_many([&src, &audioconvert, &audioresample, &volume_elem, &audiosink])?; + gst::Element::link_many([&audioconvert, &audioresample, &volume_elem, &audiosink])?; + + let audioconvert_weak = audioconvert.downgrade(); + src.connect_pad_added(move |_, pad| { + if let Some(ac) = audioconvert_weak.upgrade() { + let sink_pad = ac.static_pad("sink").unwrap(); + if !sink_pad.is_linked() { + let _ = pad.link(&sink_pad); + } + } + }); + + Ok(pipeline) +} + + + + +/// Lista de archivos/directorios que se incluyen en el respaldo. +/// Devuelve Vec<(ruta_absoluta, ruta_dentro_del_zip)> +fn backup_file_list(home: &PathBuf) -> Vec<(PathBuf, String)> { + let gradio = home.join(".gradio/data"); + let mut items: Vec<(PathBuf, String)> = Vec::new(); + + // Archivo de configuración principal + items.push(( + gradio.join("tmp/gradio.config"), + ".gradio/data/tmp/gradio.config".to_string(), + )); + + // Configuración de la botonera + items.push(( + gradio.join("botonera/config.json"), + ".gradio/data/botonera/config.json".to_string(), + )); + + // Directorios completos: parrilla, eventos, eventos-espera + for dir_name in &["parrilla", "eventos", "eventos-espera", "comerciales"] { + let dir = gradio.join(dir_name); + if dir.exists() { + collect_dir_files(&dir, home, &mut items); + } + } + + items +} + +/// Recorre un directorio recursivamente y agrega cada archivo a la lista. +fn collect_dir_files(dir: &PathBuf, home: &PathBuf, items: &mut Vec<(PathBuf, String)>) { + let Ok(entries) = fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_dir_files(&path, home, items); + } else { + // Ruta relativa al home para preservar estructura + if let Ok(rel) = path.strip_prefix(home) { + items.push((path.clone(), rel.to_string_lossy().to_string())); + } + } + } +} + +/// Crea el zip de respaldo en `dest_path`. +fn create_backup_zip(dest_path: &PathBuf, home: &PathBuf) -> Result { + let file = std::fs::File::create(dest_path)?; + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + let file_list = backup_file_list(home); + let mut count = 0usize; + + for (abs_path, zip_path) in &file_list { + if !abs_path.exists() { + info!("Respaldo: omitiendo ausente: {:?}", abs_path); + continue; + } + // Crear directorios intermedios en el zip + let zip_path_norm = zip_path.replace('\\', "/"); + if let Some(parent) = std::path::Path::new(&zip_path_norm).parent() { + let parent_str = parent.to_string_lossy(); + if !parent_str.is_empty() { + let _ = zip.add_directory(&format!("{}/", parent_str), options); + } + } + zip.start_file(&zip_path_norm, options)?; + let data = fs::read(abs_path)?; + use std::io::Write; + zip.write_all(&data)?; + count += 1; + info!("Respaldo: agregado {}", zip_path_norm); + } + + zip.finish()?; + Ok(count) +} + +/// Restaura los archivos desde un zip de respaldo. +fn restore_backup_zip(zip_path: &PathBuf, home: &PathBuf) -> Result { + let file = std::fs::File::open(zip_path)?; + let mut archive = zip::ZipArchive::new(file)?; + let mut count = 0usize; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let entry_name = entry.name().to_string(); + // Saltar entradas de directorio + if entry_name.ends_with('/') { continue; } + + let dest = home.join(&entry_name); + // Crear directorios si no existen + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + let mut out = std::fs::File::create(&dest)?; + use std::io::Read; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + use std::io::Write; + out.write_all(&buf)?; + count += 1; + info!("Recuperar: restaurado {:?}", dest); + } + + Ok(count) +} + +/// Muestra un diálogo de información simple. +fn show_info_dialog(parent: >k4::Window, title: &str, msg: &str) { + show_info_dialog_then(parent, title, msg, || {}); +} + +/// Igual que `show_info_dialog`, pero ejecuta `on_close` cuando el usuario +/// cierra la ventana. Sirve para encadenar acciones (ej. cerrar el diálogo +/// padre solo después de que el aviso fue visto y descartado) sin destruir +/// al padre mientras un modal hijo está vivo (eso colgaba la app). +fn show_info_dialog_then( + parent: >k4::Window, + title: &str, + msg: &str, + on_close: F, +) { + let d = gtk4::Window::new(); + d.set_title(Some(title)); + d.set_transient_for(Some(parent)); + d.set_modal(true); + d.set_default_size(400, -1); + let vbox = GtkBox::new(Orientation::Vertical, 12); + vbox.set_margin_top(20); vbox.set_margin_bottom(16); + vbox.set_margin_start(20); vbox.set_margin_end(20); + let lbl = Label::new(Some(msg)); + lbl.set_wrap(true); + lbl.set_halign(gtk4::Align::Start); + lbl.add_css_class("status-label"); + let btn = Button::with_label(i18n::tr("btn.ok")); + btn.add_css_class("control-btn"); + btn.set_halign(gtk4::Align::End); + let dc = d.clone(); + btn.connect_clicked(move |_| dc.close()); + vbox.append(&lbl); + vbox.append(&btn); + d.set_child(Some(&vbox)); + let cb = std::rc::Rc::new(on_close); + d.connect_close_request(move |_| { + cb(); + gtk4::glib::Propagation::Proceed + }); + d.present(); +} + +/// Ventana auxiliar para gestionar listas de carpetas. +/// Muestra la lista con botón derecho para borrar y botón para agregar nuevas. +fn show_excluded_dirs_dialog( + parent: >k4::Window, + model: std::rc::Rc>>, + count_label: Label, + title: &str, +) { + let win = gtk4::Window::new(); + win.set_title(Some(title)); + win.set_transient_for(Some(parent)); + win.set_modal(true); + win.set_default_size(500, 320); + + let vbox = GtkBox::new(Orientation::Vertical, 8); + vbox.set_margin_top(12); + vbox.set_margin_bottom(12); + vbox.set_margin_start(12); + vbox.set_margin_end(12); + + // Lista + let listbox = ListBox::new(); + listbox.add_css_class("playlist-list"); + listbox.set_selection_mode(gtk4::SelectionMode::None); + + let scroll = ScrolledWindow::new(); + scroll.set_child(Some(&listbox)); + scroll.set_vexpand(true); + scroll.set_hexpand(true); + + // Función para reconstruir la lista + let rebuild_list = { + let listbox_r = listbox.clone(); + let model_r = model.clone(); + let count_r = count_label.clone(); + move || { + while let Some(ch) = listbox_r.first_child() { listbox_r.remove(&ch); } + let items = model_r.borrow().clone(); + count_r.set_text(&i18n::tr("cfg.folders_n").replace("{n}", &items.len().to_string())); + for (i, path) in items.iter().enumerate() { + let row_box = GtkBox::new(Orientation::Horizontal, 4); + let lbl = Label::new(Some(path.as_str())); + lbl.set_halign(gtk4::Align::Start); + lbl.set_hexpand(true); + lbl.set_ellipsize(gtk4::pango::EllipsizeMode::Start); + row_box.append(&lbl); + + let row = ListBoxRow::new(); + row.set_child(Some(&row_box)); + + // Botón derecho: menú con Eliminar + let pop = gtk4::Popover::new(); + pop.set_parent(&row_box); + pop.set_has_arrow(false); + pop.set_autohide(true); + let vp = GtkBox::new(Orientation::Vertical, 0); + vp.add_css_class("context-menu-box"); + vp.set_margin_top(2); vp.set_margin_bottom(2); + vp.set_margin_start(2); vp.set_margin_end(2); + let btn_del = Button::with_label("🗑 Eliminar"); + btn_del.add_css_class("context-menu-item"); + btn_del.add_css_class("context-menu-item-danger"); + vp.append(&btn_del); + pop.set_child(Some(&vp)); + + let model_del = model_r.clone(); + let count_del = count_r.clone(); + let lb_del = listbox_r.clone(); + let pop_del = pop.clone(); + btn_del.connect_clicked(move |_| { + pop_del.popdown(); + model_del.borrow_mut().remove(i); + count_del.set_text(&i18n::tr("cfg.folders_n").replace("{n}", &model_del.borrow().len().to_string())); + while let Some(ch) = lb_del.first_child() { lb_del.remove(&ch); } + // Re-poblar (llamada indirecta vía señal de modelo) + let items2 = model_del.borrow().clone(); + for path2 in &items2 { + let rb2 = GtkBox::new(Orientation::Horizontal, 4); + let l2 = Label::new(Some(path2.as_str())); + l2.set_halign(gtk4::Align::Start); + l2.set_hexpand(true); + l2.set_ellipsize(gtk4::pango::EllipsizeMode::Start); + rb2.append(&l2); + let r2 = ListBoxRow::new(); + r2.set_child(Some(&rb2)); + lb_del.append(&r2); + } + count_del.set_text(&i18n::tr("cfg.folders_n").replace("{n}", &items2.len().to_string())); + }); + + let right = GestureClick::new(); + right.set_button(3); + let pop_r = pop.clone(); + right.connect_released(move |g, _, x, y| { + g.set_state(gtk4::EventSequenceState::Claimed); + pop_r.set_pointing_to(Some(&gdk4::Rectangle::new(x as i32, y as i32, 1, 1))); + pop_r.popup(); + }); + row_box.add_controller(right); + + listbox_r.append(&row); + } + } + }; + rebuild_list(); + + // Botón Agregar carpeta + let btn_add = Button::with_label("➕ Agregar carpeta…"); + btn_add.add_css_class("control-btn"); + { + let model_add = model.clone(); + let win_add = win.clone(); + let rebuild_c = rebuild_list.clone(); + btn_add.connect_clicked(move |_| { + let fc = gtk4::FileChooserDialog::new( + Some("Agregar carpeta"), + Some(&win_add), + gtk4::FileChooserAction::SelectFolder, + &[("Cancelar", gtk4::ResponseType::Cancel), + ("Agregar", gtk4::ResponseType::Accept)], + ); + fc.set_modal(true); + let model_c = model_add.clone(); + let rebuild_c2 = rebuild_c.clone(); + fc.connect_response(move |fc, resp| { + if resp == gtk4::ResponseType::Accept { + if let Some(file) = fc.file() { + if let Some(path) = file.path() { + let path_str = path.to_string_lossy().to_string(); + if !model_c.borrow().contains(&path_str) { + model_c.borrow_mut().push(path_str); + } + rebuild_c2(); + } + } + } + fc.close(); + }); + fc.present(); + }); + } + + let btn_close = Button::with_label("✔ Cerrar"); + btn_close.add_css_class("control-btn"); + let win_c = win.clone(); + btn_close.connect_clicked(move |_| win_c.close()); + + let btn_row = GtkBox::new(Orientation::Horizontal, 8); + btn_row.set_halign(gtk4::Align::End); + btn_row.append(&btn_add); + btn_row.append(&btn_close); + + vbox.append(&scroll); + vbox.append(&btn_row); + win.set_child(Some(&vbox)); + win.present(); +} + +/// Evita el "scroll-steal" clásico de GTK: un GtkComboBoxText/GtkSpinButton +/// sin foco que esté bajo el cursor cambia su valor con la rueda del mouse +/// en vez de dejar que el ScrolledWindow contenedor haga scroll. Reenvía el +/// scroll al ScrolledWindow cuando el widget no tiene el foco. +fn guard_scroll_steal + Clone>(widget: &W, scroll: &ScrolledWindow) { + let ctrl = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); + ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); + let w: gtk4::Widget = widget.clone().upcast(); + let scroll = scroll.clone(); + ctrl.connect_scroll(move |_, _dx, dy| { + if w.has_focus() { + return glib::Propagation::Proceed; + } + let vadj = scroll.vadjustment(); + let max = (vadj.upper() - vadj.page_size()).max(vadj.lower()); + vadj.set_value((vadj.value() + dy * vadj.step_increment()).clamp(vadj.lower(), max)); + glib::Propagation::Stop + }); + widget.add_controller(ctrl); +} + +/// Muestra el diálogo de Configuración GR +fn show_config_dialog(parent: >k4::Window) { + let dialog = gtk4::Window::new(); + dialog.set_title(Some(i18n::tr("win.config"))); + dialog.set_transient_for(Some(parent)); + dialog.set_modal(true); + dialog.set_default_size(640, 740); + dialog.set_resizable(true); + + let cfg = read_gradio_config(); + let devices = get_pulse_devices(); + let home_vol = home_dir(); + let upvol_cur = read_f64_from_file(&home_vol.join(".gradio/data/tmp/upvol")).clamp(0.0, 100.0); + let downvol_cur = read_f64_from_file(&home_vol.join(".gradio/data/tmp/downvol")).clamp(0.0, 100.0); + + let grid = gtk4::Grid::new(); + grid.set_margin_top(16); + grid.set_margin_bottom(8); + grid.set_margin_start(16); + grid.set_margin_end(16); + grid.set_row_spacing(10); + grid.set_column_spacing(12); + + // ── Fila 0: Tarjeta principal ── + let lbl_main = gtk4::Label::new(Some(i18n::tr("cfg.audio_main"))); + lbl_main.set_halign(gtk4::Align::End); + lbl_main.add_css_class("config-label"); + let combo_main = gtk4::DropDown::from_strings( + &devices.iter().map(|s| s.as_str()).collect::>() + ); + combo_main.set_hexpand(true); + if let Some(ref cur) = cfg.main_dev { + if let Some(pos) = devices.iter().position(|d| d == cur) { + combo_main.set_selected(pos as u32); + } + } + grid.attach(&lbl_main, 0, 0, 1, 1); + grid.attach(&combo_main, 1, 0, 1, 1); + + // ── Fila 1: Tarjeta CUE ── + let lbl_cue = gtk4::Label::new(Some(i18n::tr("cfg.audio_cue"))); + lbl_cue.set_halign(gtk4::Align::End); + lbl_cue.add_css_class("config-label"); + let combo_cue = gtk4::DropDown::from_strings( + &devices.iter().map(|s| s.as_str()).collect::>() + ); + combo_cue.set_hexpand(true); + if let Some(ref cur) = cfg.cue_dev { + if let Some(pos) = devices.iter().position(|d| d == cur) { + combo_cue.set_selected(pos as u32); + } + } + grid.attach(&lbl_cue, 0, 1, 1, 1); + grid.attach(&combo_cue, 1, 1, 1, 1); + + // ── Fila 2: Nombre de la radio ── + let lbl_name = gtk4::Label::new(Some(i18n::tr("cfg.medio_name"))); + lbl_name.set_halign(gtk4::Align::End); + lbl_name.add_css_class("config-label"); + let entry_name = gtk4::Entry::new(); + entry_name.set_text(&cfg.station_name); + entry_name.set_hexpand(true); + grid.attach(&lbl_name, 0, 2, 1, 1); + grid.attach(&entry_name, 1, 2, 1, 1); + + // ── Fila 3: Tiempo de fundido ── + let lbl_mix = gtk4::Label::new(Some(i18n::tr("cfg.crossfade"))); + lbl_mix.set_halign(gtk4::Align::End); + lbl_mix.add_css_class("config-label"); + let spin_mix = gtk4::SpinButton::with_range(0.0, 30.0, 0.5); + spin_mix.set_value(cfg.crossfade_secs); + spin_mix.set_digits(1); + grid.attach(&lbl_mix, 0, 3, 1, 1); + grid.attach(&spin_mix, 1, 3, 1, 1); + + // ── Fila 4: Pisador sobre temas ── + let lbl_pis_en = gtk4::Label::new(Some(i18n::tr("cfg.pisador_on"))); + lbl_pis_en.set_halign(gtk4::Align::End); + lbl_pis_en.add_css_class("config-label"); + let switch_pis = gtk4::Switch::new(); + switch_pis.set_active(cfg.pisador_enabled); + switch_pis.set_halign(gtk4::Align::Start); + grid.attach(&lbl_pis_en, 0, 4, 1, 1); + grid.attach(&switch_pis, 1, 4, 1, 1); + + // ── Fila 5: Carpeta de pisadores ── + let lbl_pis_dir = gtk4::Label::new(Some(i18n::tr("cfg.pisador_dir"))); + lbl_pis_dir.set_halign(gtk4::Align::End); + lbl_pis_dir.add_css_class("config-label"); + let entry_pis_dir = gtk4::Entry::new(); + entry_pis_dir.set_text(&cfg.pisador_dir); + entry_pis_dir.set_hexpand(true); + let btn_pis_browse = Button::with_label("📂"); + btn_pis_browse.set_tooltip_text(Some(i18n::tr("btn.choose_folder"))); + let pisdir_row = GtkBox::new(Orientation::Horizontal, 4); + pisdir_row.set_hexpand(true); + pisdir_row.append(&entry_pis_dir); + pisdir_row.append(&btn_pis_browse); + grid.attach(&lbl_pis_dir, 0, 5, 1, 1); + grid.attach(&pisdir_row, 1, 5, 1, 1); + + // Señal del botón de carpeta pisadores + { + let entry_ref = entry_pis_dir.clone(); + let win_ref = dialog.clone(); + btn_pis_browse.connect_clicked(move |_| { + let fc = gtk4::FileChooserDialog::new( + Some(i18n::tr("cfg.pick_pisador")), + Some(&win_ref), + gtk4::FileChooserAction::SelectFolder, + &[(i18n::tr("btn.cancel"), gtk4::ResponseType::Cancel), + (i18n::tr("btn.select"), gtk4::ResponseType::Accept)], + ); + fc.set_modal(true); + if !entry_ref.text().is_empty() { + let _ = fc.set_current_folder( + Some(&gio::File::for_path(entry_ref.text().as_str())) + ); + } + let entry_c = entry_ref.clone(); + fc.connect_response(move |fc, resp| { + if resp == gtk4::ResponseType::Accept { + if let Some(file) = fc.file() { + if let Some(path) = file.path() { + entry_c.set_text(&path.to_string_lossy()); + } + } + } + fc.close(); + }); + fc.present(); + }); + } + + // ── Fila 6: Cada cuántos temas ── + let lbl_pis_ev = gtk4::Label::new(Some(i18n::tr("cfg.pisador_every"))); + lbl_pis_ev.set_halign(gtk4::Align::End); + lbl_pis_ev.add_css_class("config-label"); + let spin_pis_ev = gtk4::SpinButton::with_range(1.0, 99.0, 1.0); + spin_pis_ev.set_value(cfg.pisador_every as f64); + spin_pis_ev.set_digits(0); + grid.attach(&lbl_pis_ev, 0, 6, 1, 1); + grid.attach(&spin_pis_ev, 1, 6, 1, 1); + + // ── Fila 7: Carpetas excluidas — botón que abre ventana auxiliar ── + let lbl_excl = gtk4::Label::new(Some(i18n::tr("cfg.pisador_excl"))); + lbl_excl.set_halign(gtk4::Align::End); + lbl_excl.add_css_class("config-label"); + // Modelo compartido de exclusiones (Rc para pasar entre closures GTK) + let excl_model: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(cfg.pisador_exclude.clone())); + let btn_excl_edit = Button::with_label(i18n::tr("btn.edit_list")); + btn_excl_edit.add_css_class("control-btn"); + btn_excl_edit.set_hexpand(false); + // Etiqueta que muestra cuántas hay + let lbl_excl_count = Label::new(Some( + &i18n::tr("cfg.folders_n").replace("{n}", &cfg.pisador_exclude.len().to_string()) + )); + lbl_excl_count.add_css_class("time-label"); + let excl_row = GtkBox::new(Orientation::Horizontal, 8); + excl_row.append(&btn_excl_edit); + excl_row.append(&lbl_excl_count); + grid.attach(&lbl_excl, 0, 7, 1, 1); + grid.attach(&excl_row, 1, 7, 1, 1); + + // Señal: abrir ventana auxiliar de lista de excluidas + { + let model_ref = excl_model.clone(); + let count_ref = lbl_excl_count.clone(); + let win_ref = dialog.clone(); + btn_excl_edit.connect_clicked(move |_| { + show_excluded_dirs_dialog( + &win_ref, + model_ref.clone(), + count_ref.clone(), + "Carpetas que no se pisan", + ); + }); + } + + // ── Fila 8: Carpetas Nacionales ── + let lbl_nac = gtk4::Label::new(Some(i18n::tr("cfg.nacionales"))); + lbl_nac.set_halign(gtk4::Align::End); + lbl_nac.add_css_class("config-label"); + let nac_model: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(cfg.carpetas_nacionales.clone())); + let btn_nac_edit = Button::with_label(i18n::tr("btn.edit_list")); + btn_nac_edit.add_css_class("control-btn"); + btn_nac_edit.set_hexpand(false); + let lbl_nac_count = Label::new(Some( + &i18n::tr("cfg.folders_n").replace("{n}", &cfg.carpetas_nacionales.len().to_string()) + )); + lbl_nac_count.add_css_class("time-label"); + let nac_row = GtkBox::new(Orientation::Horizontal, 8); + nac_row.append(&btn_nac_edit); + nac_row.append(&lbl_nac_count); + grid.attach(&lbl_nac, 0, 8, 1, 1); + grid.attach(&nac_row, 1, 8, 1, 1); + { + let model_ref = nac_model.clone(); + let count_ref = lbl_nac_count.clone(); + let win_ref = dialog.clone(); + btn_nac_edit.connect_clicked(move |_| { + show_excluded_dirs_dialog( + &win_ref, model_ref.clone(), count_ref.clone(), + "Carpetas Nacionales", + ); + }); + } + + // ── Fila 9: Carpetas Intercultural ── + let lbl_int = gtk4::Label::new(Some(i18n::tr("cfg.intercultural"))); + lbl_int.set_halign(gtk4::Align::End); + lbl_int.add_css_class("config-label"); + let int_model: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(cfg.carpetas_intercultural.clone())); + let btn_int_edit = Button::with_label(i18n::tr("btn.edit_list")); + btn_int_edit.add_css_class("control-btn"); + btn_int_edit.set_hexpand(false); + let lbl_int_count = Label::new(Some( + &i18n::tr("cfg.folders_n").replace("{n}", &cfg.carpetas_intercultural.len().to_string()) + )); + lbl_int_count.add_css_class("time-label"); + let int_row = GtkBox::new(Orientation::Horizontal, 8); + int_row.append(&btn_int_edit); + int_row.append(&lbl_int_count); + grid.attach(&lbl_int, 0, 9, 1, 1); + grid.attach(&int_row, 1, 9, 1, 1); + { + let model_ref = int_model.clone(); + let count_ref = lbl_int_count.clone(); + let win_ref = dialog.clone(); + btn_int_edit.connect_clicked(move |_| { + show_excluded_dirs_dialog( + &win_ref, model_ref.clone(), count_ref.clone(), + "Carpetas Intercultural", + ); + }); + } + + // ── Fila 10: Detector de silencio ── + let lbl_silence = gtk4::Label::new(Some(i18n::tr("cfg.silence"))); + lbl_silence.set_halign(gtk4::Align::End); + lbl_silence.add_css_class("config-label"); + let spin_silence = gtk4::SpinButton::with_range(0.0, 60.0, 1.0); + spin_silence.set_value(cfg.silence_secs); + spin_silence.set_digits(0); + spin_silence.set_tooltip_text(Some(i18n::tr("tip.silence"))); + grid.attach(&lbl_silence, 0, 10, 1, 1); + grid.attach(&spin_silence, 1, 10, 1, 1); + + // ── Fila 11: Volumen principal ── + let lbl_upvol = gtk4::Label::new(Some(i18n::tr("cfg.upvol"))); + lbl_upvol.set_halign(gtk4::Align::End); + lbl_upvol.add_css_class("config-label"); + let spin_upvol = gtk4::SpinButton::with_range(0.0, 100.0, 1.0); + spin_upvol.set_value(upvol_cur); + spin_upvol.set_digits(0); + spin_upvol.set_tooltip_text(Some(i18n::tr("tip.upvol"))); + grid.attach(&lbl_upvol, 0, 11, 1, 1); + grid.attach(&spin_upvol, 1, 11, 1, 1); + + // ── Fila 12: Volumen duck ── + let lbl_downvol = gtk4::Label::new(Some(i18n::tr("cfg.downvol"))); + lbl_downvol.set_halign(gtk4::Align::End); + lbl_downvol.add_css_class("config-label"); + let spin_downvol = gtk4::SpinButton::with_range(0.0, 100.0, 1.0); + spin_downvol.set_value(downvol_cur); + spin_downvol.set_digits(0); + spin_downvol.set_tooltip_text(Some(i18n::tr("tip.downvol"))); + grid.attach(&lbl_downvol, 0, 12, 1, 1); + grid.attach(&spin_downvol, 1, 12, 1, 1); + + // ── Fila 13: Puerto del servidor gr-client ── + let lbl_port = gtk4::Label::new(Some(i18n::tr("cfg.client_port"))); + lbl_port.set_halign(gtk4::Align::End); + lbl_port.add_css_class("config-label"); + let spin_port = gtk4::SpinButton::with_range(1.0, 65535.0, 1.0); + spin_port.set_value(cfg.client_port as f64); + spin_port.set_digits(0); + spin_port.set_tooltip_text(Some(i18n::tr("tip.client_port"))); + grid.attach(&lbl_port, 0, 13, 1, 1); + grid.attach(&spin_port, 1, 13, 1, 1); + + // ── Fila 14: Token de acceso gr-client ── + let lbl_tok = gtk4::Label::new(Some(i18n::tr("cfg.client_token"))); + lbl_tok.set_halign(gtk4::Align::End); + lbl_tok.add_css_class("config-label"); + let entry_tok = gtk4::Entry::new(); + entry_tok.set_text(&cfg.client_token); + entry_tok.set_hexpand(true); + entry_tok.set_visibility(false); + entry_tok.set_input_purpose(gtk4::InputPurpose::Password); + entry_tok.set_placeholder_text(Some(i18n::tr("cfg.client_token_ph"))); + entry_tok.set_tooltip_text(Some(i18n::tr("tip.client_token"))); + grid.attach(&lbl_tok, 0, 14, 1, 1); + grid.attach(&entry_tok, 1, 14, 1, 1); + + // ── Fila 15: Relay internet habilitado ── + let lbl_relay = gtk4::Label::new(Some(i18n::tr("cfg.relay"))); + lbl_relay.set_halign(gtk4::Align::End); + lbl_relay.add_css_class("config-label"); + let switch_relay = gtk4::Switch::new(); + switch_relay.set_active(cfg.relay_habilitado); + switch_relay.set_halign(gtk4::Align::Start); + switch_relay.set_tooltip_text(Some(i18n::tr("tip.relay"))); + grid.attach(&lbl_relay, 0, 15, 1, 1); + grid.attach(&switch_relay, 1, 15, 1, 1); + + // ── Fila 16: ID de relay ── + let lbl_rid = gtk4::Label::new(Some(i18n::tr("cfg.relay_id"))); + lbl_rid.set_halign(gtk4::Align::End); + lbl_rid.add_css_class("config-label"); + let relay_id_box = GtkBox::new(Orientation::Horizontal, 6); + let entry_rid = gtk4::Entry::new(); + entry_rid.set_text(&cfg.relay_id); + entry_rid.set_hexpand(true); + entry_rid.set_max_length(8); + entry_rid.set_placeholder_text(Some("12345678")); + entry_rid.set_tooltip_text(Some(i18n::tr("tip.relay_id"))); + let btn_gen_id = gtk4::Button::with_label(i18n::tr("btn.generate")); + btn_gen_id.set_tooltip_text(Some(i18n::tr("tip.gen_id"))); + { + let entry_rid2 = entry_rid.clone(); + btn_gen_id.connect_clicked(move |_| { + use std::time::{SystemTime, UNIX_EPOCH}; + // ID pseudoaleatorio de 8 dígitos + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(12345678); + // Mezcla simple para distribuir los dígitos + let id = format!("{:08}", (seed as u64 * 6364136223846793005 + 1) % 100_000_000); + entry_rid2.set_text(&id); + }); + } + relay_id_box.append(&entry_rid); + relay_id_box.append(&btn_gen_id); + grid.attach(&lbl_rid, 0, 16, 1, 1); + grid.attach(&relay_id_box, 1, 16, 1, 1); + + // ── Fila 17: Días de no-repetición ── + let lbl_days = gtk4::Label::new(Some(i18n::tr("cfg.no_repeat"))); + lbl_days.set_halign(gtk4::Align::End); + lbl_days.add_css_class("config-label"); + let spin_days = gtk4::SpinButton::with_range(1.0, 30.0, 1.0); + spin_days.set_value(cfg.no_repeat_days as f64); + spin_days.set_digits(0); + spin_days.set_tooltip_text(Some(i18n::tr("tip.no_repeat"))); + grid.attach(&lbl_days, 0, 17, 1, 1); + grid.attach(&spin_days, 1, 17, 1, 1); + + // ── Fila 18: Idioma de la UI ── + let lbl_locale = gtk4::Label::new(Some(i18n::tr("lang.label"))); + lbl_locale.set_halign(gtk4::Align::End); + lbl_locale.add_css_class("config-label"); + let combo_locale = gtk4::ComboBoxText::new(); + combo_locale.append(Some("auto"), 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")); } + grid.attach(&lbl_locale, 0, 18, 1, 1); + grid.attach(&combo_locale, 1, 18, 1, 1); + + // ── Fila 19: Inteligencia Artificial ── + let lbl_ia = gtk4::Label::new(Some(i18n::tr("cfg.ia"))); + lbl_ia.set_halign(gtk4::Align::End); + lbl_ia.add_css_class("config-label"); + let switch_ia = gtk4::Switch::new(); + switch_ia.set_active(cfg.ia_habilitada); + switch_ia.set_halign(gtk4::Align::Start); + switch_ia.set_tooltip_text(Some(i18n::tr("tip.ia"))); + grid.attach(&lbl_ia, 0, 19, 1, 1); + grid.attach(&switch_ia, 1, 19, 1, 1); + + // Advertencia al habilitar IA: mostrar diálogo de aceptación explícita + { + let sw_ia = switch_ia.clone(); + let dlg_parent = dialog.clone(); + // Flag de uso único para evitar re-entrada al hacer set_active desde el diálogo + let ia_accepted = std::rc::Rc::new(std::cell::Cell::new(cfg.ia_habilitada)); + let ia_accepted_c = ia_accepted.clone(); + switch_ia.connect_state_set(move |sw, new_state| { + if new_state && !ia_accepted_c.get() { + // Bloquear: mostrar diálogo de advertencia + let warn = gtk4::Window::new(); + warn.set_title(Some(i18n::tr("dlg.ia_warn_title"))); + warn.set_transient_for(Some(&dlg_parent)); + warn.set_modal(true); + warn.set_default_size(460, -1); + let vbox = GtkBox::new(Orientation::Vertical, 14); + vbox.set_margin_top(20); vbox.set_margin_bottom(16); + vbox.set_margin_start(20); vbox.set_margin_end(20); + let lbl_w = gtk4::Label::new(Some(i18n::tr("dlg.ia_warn_body"))); + lbl_w.set_wrap(true); + lbl_w.set_halign(gtk4::Align::Start); + lbl_w.add_css_class("status-label"); + let btn_row = GtkBox::new(Orientation::Horizontal, 8); + btn_row.set_halign(gtk4::Align::End); + let btn_no = Button::with_label(i18n::tr("btn.cancel")); + let btn_yes = Button::with_label(i18n::tr("dlg.ia_accept")); + btn_no.add_css_class("control-btn"); + btn_yes.add_css_class("control-btn"); + let sw_c = sw_ia.clone(); + let ia_c = ia_accepted_c.clone(); + let wc = warn.clone(); + btn_yes.connect_clicked(move |_| { + ia_c.set(true); + sw_c.set_active(true); + wc.close(); + }); + let wc2 = warn.clone(); + btn_no.connect_clicked(move |_| { wc2.close(); }); + btn_row.append(&btn_no); + btn_row.append(&btn_yes); + vbox.append(&lbl_w); + vbox.append(&btn_row); + warn.set_child(Some(&vbox)); + warn.present(); + return glib::Propagation::Stop; // bloquear cambio de estado hasta aceptación + } + if !new_state { + ia_accepted_c.set(false); + } + glib::Propagation::Proceed // permitir + }); + // Silenciar la advertencia en toggles programáticos (set_active desde btn_yes) + let _ = sw_ia; // mantener referencia viva + } + + // ── Fila 20: Locutor Automático (locución IA, ver Dropbox/LocutorIA) ── + // Solo una bandera de intención por ahora: no lanza nada este proceso. + // Agentes externos (claude/opencode) leen tmp/locutor_auto para decidir + // si generan locuciones y las insertan como evento/comercial. + let lbl_locutor = gtk4::Label::new(Some(i18n::tr("cfg.locutor"))); + lbl_locutor.set_halign(gtk4::Align::End); + lbl_locutor.add_css_class("config-label"); + let switch_locutor = gtk4::Switch::new(); + switch_locutor.set_active(cfg.locutor_habilitado); + switch_locutor.set_halign(gtk4::Align::Start); + switch_locutor.set_tooltip_text(Some(i18n::tr("tip.locutor"))); + grid.attach(&lbl_locutor, 0, 20, 1, 1); + grid.attach(&switch_locutor, 1, 20, 1, 1); + + // Advertencia al habilitar Locutor Automático: mismo patrón que IA — + // deja claro que la locución la genera un agente externo (Claude/OpenCode), + // no este proceso, antes de aceptar explícitamente. + { + let sw_locutor = switch_locutor.clone(); + let dlg_parent = dialog.clone(); + let locutor_accepted = std::rc::Rc::new(std::cell::Cell::new(cfg.locutor_habilitado)); + let locutor_accepted_c = locutor_accepted.clone(); + switch_locutor.connect_state_set(move |sw, new_state| { + if new_state && !locutor_accepted_c.get() { + let warn = gtk4::Window::new(); + warn.set_title(Some(i18n::tr("dlg.locutor_warn_title"))); + warn.set_transient_for(Some(&dlg_parent)); + warn.set_modal(true); + warn.set_default_size(460, -1); + let vbox = GtkBox::new(Orientation::Vertical, 14); + vbox.set_margin_top(20); vbox.set_margin_bottom(16); + vbox.set_margin_start(20); vbox.set_margin_end(20); + let lbl_w = gtk4::Label::new(Some(i18n::tr("dlg.locutor_warn_body"))); + lbl_w.set_wrap(true); + lbl_w.set_halign(gtk4::Align::Start); + lbl_w.add_css_class("status-label"); + let btn_row = GtkBox::new(Orientation::Horizontal, 8); + btn_row.set_halign(gtk4::Align::End); + let btn_no = Button::with_label(i18n::tr("btn.cancel")); + let btn_yes = Button::with_label(i18n::tr("dlg.locutor_accept")); + btn_no.add_css_class("control-btn"); + btn_yes.add_css_class("control-btn"); + let sw_c = sw_locutor.clone(); + let loc_c = locutor_accepted_c.clone(); + let wc = warn.clone(); + btn_yes.connect_clicked(move |_| { + loc_c.set(true); + sw_c.set_active(true); + wc.close(); + }); + let wc2 = warn.clone(); + btn_no.connect_clicked(move |_| { wc2.close(); }); + btn_row.append(&btn_no); + btn_row.append(&btn_yes); + vbox.append(&lbl_w); + vbox.append(&btn_row); + warn.set_child(Some(&vbox)); + warn.present(); + return glib::Propagation::Stop; + } + if !new_state { + locutor_accepted_c.set(false); + } + glib::Propagation::Proceed + }); + let _ = sw_locutor; + } + + // ── Fila 21: Carpetas que no se funden ── + let lbl_fundido = gtk4::Label::new(Some(i18n::tr("cfg.fundido_excl"))); + lbl_fundido.set_halign(gtk4::Align::End); + lbl_fundido.add_css_class("config-label"); + let fundido_model: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(cfg.fundido_exclude.clone())); + let btn_fundido_edit = Button::with_label(i18n::tr("btn.edit_list")); + btn_fundido_edit.add_css_class("control-btn"); + btn_fundido_edit.set_hexpand(false); + btn_fundido_edit.set_tooltip_text(Some(i18n::tr("tip.fundido_excl"))); + let lbl_fundido_count = Label::new(Some( + &i18n::tr("cfg.folders_n").replace("{n}", &cfg.fundido_exclude.len().to_string()) + )); + lbl_fundido_count.add_css_class("time-label"); + let fundido_row = GtkBox::new(Orientation::Horizontal, 8); + fundido_row.append(&btn_fundido_edit); + fundido_row.append(&lbl_fundido_count); + grid.attach(&lbl_fundido, 0, 21, 1, 1); + grid.attach(&fundido_row, 1, 21, 1, 1); + { + let model_ref = fundido_model.clone(); + let count_ref = lbl_fundido_count.clone(); + let win_ref = dialog.clone(); + btn_fundido_edit.connect_clicked(move |_| { + show_excluded_dirs_dialog( + &win_ref, model_ref.clone(), count_ref.clone(), + "Carpetas que no se funden", + ); + }); + } + + // ── Fila 22: Skin (apariencia) ── + let lbl_skin = gtk4::Label::new(Some(i18n::tr("skin.label"))); + lbl_skin.set_halign(gtk4::Align::End); + lbl_skin.add_css_class("config-label"); + let combo_skin = gtk4::ComboBoxText::new(); + combo_skin.append(Some(""), i18n::tr("skin.default")); + for nombre in skin::listar() { + combo_skin.append(Some(&nombre), &nombre); + } + let cur_skin = skin::nombre_activo().unwrap_or_default(); + if !combo_skin.set_active_id(Some(&cur_skin)) { combo_skin.set_active_id(Some("")); } + grid.attach(&lbl_skin, 0, 22, 1, 1); + grid.attach(&combo_skin, 1, 22, 1, 1); + + // ── Fila 23: Players (paneles de reproducción visibles) ── + let lbl_players = gtk4::Label::new(Some(i18n::tr("players.label"))); + lbl_players.set_halign(gtk4::Align::End); + lbl_players.add_css_class("config-label"); + let combo_players = gtk4::ComboBoxText::new(); + combo_players.append(Some("3"), i18n::tr("players.3")); + combo_players.append(Some("2"), i18n::tr("players.2")); + combo_players.append(Some("1"), i18n::tr("players.1")); + combo_players.set_active_id(Some(&cfg.players.to_string())); + grid.attach(&lbl_players, 0, 23, 1, 1); + grid.attach(&combo_players, 1, 23, 1, 1); + + // ── Botones ── + let btn_box = GtkBox::new(Orientation::Horizontal, 8); + btn_box.set_margin_top(4); + btn_box.set_margin_bottom(16); + btn_box.set_margin_start(16); + btn_box.set_margin_end(16); + + // Izquierda: Respaldar / Recuperar + let btn_backup = Button::with_label("📦 Respaldar GR"); + let btn_restore = Button::with_label("📂 Recuperar GR"); + btn_backup.add_css_class("control-btn"); + btn_restore.add_css_class("control-btn"); + + let left_box = GtkBox::new(Orientation::Horizontal, 8); + left_box.set_hexpand(true); + left_box.append(&btn_backup); + left_box.append(&btn_restore); + + // Derecha: Cancelar / Guardar + let btn_save = Button::with_label(&format!("💾 {}", i18n::tr("btn.save"))); + let btn_cancel = Button::with_label(i18n::tr("btn.cancel")); + btn_save.add_css_class("control-btn"); + btn_cancel.add_css_class("control-btn"); + let right_box = GtkBox::new(Orientation::Horizontal, 8); + right_box.set_halign(gtk4::Align::End); + right_box.append(&btn_cancel); + right_box.append(&btn_save); + + btn_box.append(&left_box); + btn_box.append(&right_box); + + let scroll = ScrolledWindow::new(); + scroll.set_child(Some(&grid)); + scroll.set_vexpand(true); + scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); + scroll.set_vscrollbar_policy(gtk4::PolicyType::Automatic); + + // El diálogo tiene muchos combos/spinners dentro de este ScrolledWindow; + // sin esto, pasar la rueda del mouse sobre cualquiera de ellos para + // llegar a un campo más abajo cambia su valor en vez de hacer scroll + // (fue lo que le pasó al idioma: "cambio de idioma" al guardar sin + // haber tocado el combo, solo por pasar el scroll sobre él). + guard_scroll_steal(&combo_locale, &scroll); + guard_scroll_steal(&combo_skin, &scroll); + guard_scroll_steal(&combo_players, &scroll); + guard_scroll_steal(&spin_mix, &scroll); + guard_scroll_steal(&spin_pis_ev, &scroll); + guard_scroll_steal(&spin_silence, &scroll); + guard_scroll_steal(&spin_upvol, &scroll); + guard_scroll_steal(&spin_downvol, &scroll); + guard_scroll_steal(&spin_port, &scroll); + guard_scroll_steal(&spin_days, &scroll); + + let vbox = GtkBox::new(Orientation::Vertical, 0); + vbox.append(&scroll); + vbox.append(&btn_box); + dialog.set_child(Some(&vbox)); + + let dlg_cancel = dialog.clone(); + btn_cancel.connect_clicked(move |_| dlg_cancel.close()); + + // ── Respaldar GR ────────────────────────────────────────────────────────── + { + let win_bk = dialog.clone(); + let home_bk = dirs::home_dir().unwrap(); + btn_backup.connect_clicked(move |_| { + let fc = gtk4::FileChooserDialog::new( + Some("Guardar respaldo GR"), + Some(&win_bk), + gtk4::FileChooserAction::Save, + &[("Cancelar", gtk4::ResponseType::Cancel), + ("Guardar", gtk4::ResponseType::Accept)], + ); + fc.set_modal(true); + // Nombre sugerido con fecha + let fecha = chrono::Local::now().format("%Y%m%d-%H%M").to_string(); + fc.set_current_name(&format!("gradio-backup-{}.zip", fecha)); + // Filtro zip + let filter = gtk4::FileFilter::new(); + filter.set_name(Some(i18n::tr("dlg.filter_zip"))); + filter.add_pattern("*.zip"); + fc.add_filter(&filter); + + let home_c = home_bk.clone(); + let win_c = win_bk.clone(); + fc.connect_response(move |fc, resp| { + if resp == gtk4::ResponseType::Accept { + if let Some(file) = fc.file() { + if let Some(mut dest) = file.path() { + // Asegurar extensión .zip + if dest.extension().and_then(|e| e.to_str()) != Some("zip") { + dest.set_extension("zip"); + } + match create_backup_zip(&dest, &home_c) { + Ok(n) => { + info!("Respaldo creado: {:?} ({} archivos)", dest, n); + show_info_dialog( + &win_c, + i18n::tr("dlg.backup_title"), + &i18n::tr("dlg.backup_body") + .replace("{n}", &n.to_string()) + .replace("{path}", &dest.display().to_string()), + ); + } + Err(e) => { + error!("Error creando respaldo: {}", e); + show_info_dialog(&win_c, i18n::tr("dlg.error"), + &i18n::tr("dlg.backup_err").replace("{err}", &e.to_string())); + } + } + } + } + } + fc.close(); + }); + fc.present(); + }); + } + + // ── Recuperar GR ───────────────────────────────────────────────────────── + { + let win_rs = dialog.clone(); + let home_rs = dirs::home_dir().unwrap(); + btn_restore.connect_clicked(move |_| { + let fc = gtk4::FileChooserDialog::new( + Some(i18n::tr("dlg.sel_backup")), + Some(&win_rs), + gtk4::FileChooserAction::Open, + &[(i18n::tr("btn.cancel"), gtk4::ResponseType::Cancel), + (i18n::tr("dlg.btn_restore"), gtk4::ResponseType::Accept)], + ); + fc.set_modal(true); + let filter = gtk4::FileFilter::new(); + filter.set_name(Some(i18n::tr("dlg.filter_zip"))); + filter.add_pattern("*.zip"); + fc.add_filter(&filter); + + let home_c = home_rs.clone(); + let win_c = win_rs.clone(); + fc.connect_response(move |fc, resp| { + if resp == gtk4::ResponseType::Accept { + if let Some(file) = fc.file() { + if let Some(zip_path) = file.path() { + // Confirmar antes de sobreescribir + match restore_backup_zip(&zip_path, &home_c) { + Ok(n) => { + info!("Recuperación completada: {} archivos", n); + show_info_dialog( + &win_c, + i18n::tr("dlg.recovery_title"), + &i18n::tr("dlg.recovery_body").replace("{n}", &n.to_string()), + ); + } + Err(e) => { + error!("Error en recuperación: {}", e); + show_info_dialog(&win_c, i18n::tr("dlg.error"), + &i18n::tr("dlg.recovery_err").replace("{err}", &e.to_string())); + } + } + } + } + } + fc.close(); + }); + fc.present(); + }); + } + + let dlg_save = dialog.clone(); + let combo_m2 = combo_main.clone(); + let combo_c2 = combo_cue.clone(); + let entry_n2 = entry_name.clone(); + let spin_m2 = spin_mix.clone(); + let switch_p2 = switch_pis.clone(); + let entry_pd2 = entry_pis_dir.clone(); + let spin_pe2 = spin_pis_ev.clone(); + let excl_m2 = excl_model.clone(); + let spin_si2 = spin_silence.clone(); + let spin_uv2 = spin_upvol.clone(); + let spin_dv2 = spin_downvol.clone(); + let nac_m2 = nac_model.clone(); + let int_m2 = int_model.clone(); + let devs2 = devices.clone(); + let spin_port2 = spin_port.clone(); + let entry_tok2 = entry_tok.clone(); + let switch_relay2 = switch_relay.clone(); + let entry_rid2 = entry_rid.clone(); + let spin_days2 = spin_days.clone(); + let combo_locale2 = combo_locale.clone(); + let combo_skin2 = combo_skin.clone(); + let combo_players2 = combo_players.clone(); + let switch_ia2 = switch_ia.clone(); + let switch_locutor2 = switch_locutor.clone(); + let fundido_m2 = fundido_model.clone(); + btn_save.connect_clicked(move |_| { + let idx_m = combo_m2.selected() as usize; + let idx_c = combo_c2.selected() as usize; + let main_dev = devs2.get(idx_m).cloned() + .filter(|s| s != "(auto)").map(|s| if s.is_empty() { None } else { Some(s) }) + .flatten(); + let cue_dev = devs2.get(idx_c).cloned() + .filter(|s| s != "(auto)").map(|s| if s.is_empty() { None } else { Some(s) }) + .flatten(); + // Tomar locale del combo del diálogo + let prev_locale = read_gradio_config().locale; + let sel_locale_id = combo_locale2.active_id().map(|s| s.to_string()).unwrap_or_else(|| "auto".into()); + let new_locale = if sel_locale_id == "auto" { None } else { Some(sel_locale_id) }; + let locale_changed = prev_locale != new_locale; + let prev_skin = skin::nombre_activo().unwrap_or_default(); + let new_skin = combo_skin2.active_id().map(|s| s.to_string()).unwrap_or_default(); + let skin_changed = prev_skin != new_skin; + let prev_players = read_gradio_config().players; + let new_players: u8 = combo_players2.active_id() + .and_then(|s| s.parse().ok()) + .unwrap_or(3); + let players_changed = prev_players != new_players; + let new_cfg = GradioConfig { + main_dev, + cue_dev, + station_name: entry_n2.text().to_string(), + crossfade_secs: spin_m2.value(), + pisador_enabled: switch_p2.is_active(), + pisador_dir: entry_pd2.text().to_string(), + pisador_every: spin_pe2.value() as u32, + pisador_exclude: excl_m2.borrow().clone(), + silence_secs: spin_si2.value(), + carpetas_nacionales: nac_m2.borrow().clone(), + carpetas_intercultural:int_m2.borrow().clone(), + client_port: spin_port2.value() as u16, + client_token: entry_tok2.text().to_string(), + relay_habilitado: switch_relay2.is_active(), + relay_id: entry_rid2.text().to_string(), + no_repeat_days: spin_days2.value() as u32, + locale: new_locale, + ia_habilitada: switch_ia2.is_active(), + fundido_exclude: fundido_m2.borrow().clone(), + locutor_habilitado: switch_locutor2.is_active(), + players: new_players, + }; + // Validar: relay habilitado requiere token + if new_cfg.relay_habilitado && new_cfg.client_token.trim().is_empty() { + show_info_dialog( + &dlg_save, + i18n::tr("dlg.token_title"), + i18n::tr("dlg.token_body"), + ); + entry_tok2.add_css_class("error"); + entry_tok2.grab_focus(); + return; + } + entry_tok2.remove_css_class("error"); + + write_gradio_config(&new_cfg); + let home_sv = home_dir(); + let _ = fs::write(home_sv.join(".gradio/data/tmp/upvol"), format!("{}", spin_uv2.value() as u32)); + let _ = fs::write(home_sv.join(".gradio/data/tmp/downvol"), format!("{}", spin_dv2.value() as u32)); + let _ = fs::write(home_sv.join(".gradio/data/tmp/skin"), &new_skin); + info!("Configuración guardada en gradio.config"); + if locale_changed { + let dlg_to_close = dlg_save.clone(); + show_info_dialog_then( + &dlg_save, + i18n::tr("lang.restart_title"), + i18n::tr("lang.restart_body"), + move || dlg_to_close.close(), + ); + } else if skin_changed { + let dlg_to_close = dlg_save.clone(); + show_info_dialog_then( + &dlg_save, + i18n::tr("skin.restart_title"), + i18n::tr("skin.restart_body"), + move || dlg_to_close.close(), + ); + } else if players_changed { + let dlg_to_close = dlg_save.clone(); + show_info_dialog_then( + &dlg_save, + i18n::tr("players.restart_title"), + i18n::tr("players.restart_body"), + move || dlg_to_close.close(), + ); + } else { + dlg_save.close(); + } + }); + + dialog.present(); +} + +/// Devuelve lista de nombres de dispositivos PulseAudio/ALSA disponibles. +/// Usa `pactl list short sinks` si está disponible, sino devuelve lista básica. +fn get_pulse_devices() -> Vec { + let mut devices = vec!["(auto)".to_string()]; + if let Ok(out) = std::process::Command::new("pactl") + .args(["list", "short", "sinks"]) + .output() + { + for line in String::from_utf8_lossy(&out.stdout).lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + devices.push(parts[1].to_string()); + } + } + } + devices +} + +fn path_to_uri(path: &Path) -> String { + let s = path.to_string_lossy(); + if s.starts_with("http://") || s.starts_with("https://") { + return s.into_owned(); + } + #[cfg(target_os = "windows")] + { + // Convertir backslashes y percent-encodear todo excepto chars seguros. + // Espacios, tildes y caracteres no-ASCII deben codificarse byte a byte. + let fwd = s.replace('\\', "/"); + let encoded: String = fwd.bytes().map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => (b as char).to_string(), + _ => format!("%{:02X}", b), + }).collect(); + if encoded.len() >= 2 && encoded.as_bytes()[1] == b':' { + return format!("file:///{}", encoded); + } + return format!("file://{}", encoded); + } + #[cfg(not(target_os = "windows"))] + { + let encoded: String = s.bytes().map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'_' | b'.' | b'~' | b'/' => (b as char).to_string(), + _ => format!("%{:02X}", b), + }).collect(); + format!("file://{}", encoded) + } +} + +fn pipeline_position(pipeline: &gst::Pipeline) -> f64 { + pipeline + .query_position::() + .map(|t| t.seconds() as f64) + .unwrap_or(0.0) +} + +fn pipeline_duration(pipeline: &gst::Pipeline) -> f64 { + pipeline + .query_duration::() + .map(|t| t.seconds() as f64) + .unwrap_or(0.0) +} + +fn set_pipeline_volume(pipeline: &gst::Pipeline, vol: f64) { + for element in pipeline.iterate_elements().into_iter().flatten() { + if element.factory().map(|f| f.name() == "volume").unwrap_or(false) { + element.set_property("volume", vol.clamp(0.0, 1.0)); + } + } +} + +/// Libera un pipeline de GStreamer de forma segura. +/// Se ejecuta en un hilo separado para no bloquear el main loop de GTK +/// (pipeline.state() con timeout bloquearía la UI y GStreamer no podría +/// procesar su propia transición a NULL → deadlock). +fn shutdown_pipeline(pipeline: gst::Pipeline) { + // NO llamar set_bus(None) ni set_flushing antes de la transición: + // quitar el bus mientras el pipeline está en PLAYING impide que los + // elementos puedan completar el handshake de estado, haciendo que la + // transición a NULL falle y el pipeline sea descartado aún en PLAYING. + std::thread::spawn(move || { + // GStreamer maneja la cadena PLAYING→PAUSED→READY→NULL internamente. + let _ = pipeline.set_state(gst::State::Null); + // 30 segundos: suficiente para archivos locales (~inmediato) y streams lentos. + // Si expira, el pipeline cae con CRITICAL warning; con 5s ocurría con frecuencia. + let _ = pipeline.state(gst::ClockTime::from_seconds(30)); + // Silenciar el bus watch una vez que ya estamos en NULL, para + // evitar callbacks tardíos después del drop. + if let Some(bus) = pipeline.bus() { + bus.set_flushing(true); + } + // drop implicito: pipeline ya en NULL, sin warning + }); +} + +fn pipeline_seek(pipeline: &gst::Pipeline, secs: f64) { + let _ = pipeline.seek_simple( + gst::SeekFlags::FLUSH | gst::SeekFlags::KEY_UNIT, + gst::ClockTime::from_seconds(secs as u64), + ); +} + +// ─── GUI ────────────────────────────────────────────────────────────────────── + + +/// Crea un botón cuadrado con imagen PNG embebida. +/// `size` es el lado en píxeles del botón. +fn icon_button(png_bytes: &[u8], tooltip: &str, size: i32) -> Button { + let btn = Button::new(); + btn.set_tooltip_text(Some(tooltip)); + btn.set_size_request(size, size); + btn.set_hexpand(false); + btn.set_vexpand(false); + + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + loader.write(png_bytes).unwrap_or(()); + loader.close().unwrap_or(()); + if let Some(pixbuf) = loader.pixbuf() { + // Escalar al tamaño deseado (icono = size - 8px de margen) + let icon_size = (size - 10).max(16); + if let Some(scaled) = pixbuf.scale_simple( + icon_size, icon_size, + gdk4::gdk_pixbuf::InterpType::Bilinear, + ) { + let texture = gdk4::Texture::for_pixbuf(&scaled); + let image = gtk4::Image::from_paintable(Some(&texture)); + btn.set_child(Some(&image)); + } + } + btn +} + +/// Igual que icon_button pero para ToggleButton. +fn icon_toggle_button(png_bytes: &[u8], tooltip: &str, size: i32) -> ToggleButton { + let btn = ToggleButton::new(); + btn.set_tooltip_text(Some(tooltip)); + btn.set_size_request(size, size); + btn.set_hexpand(false); + btn.set_vexpand(false); + + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + loader.write(png_bytes).unwrap_or(()); + loader.close().unwrap_or(()); + if let Some(pixbuf) = loader.pixbuf() { + let icon_size = (size - 10).max(16); + if let Some(scaled) = pixbuf.scale_simple( + icon_size, icon_size, + gdk4::gdk_pixbuf::InterpType::Bilinear, + ) { + let texture = gdk4::Texture::for_pixbuf(&scaled); + let image = gtk4::Image::from_paintable(Some(&texture)); + btn.set_child(Some(&image)); + } + } + btn +} + +/// Actualiza el icono de un botón existente. +fn set_button_icon(btn: &Button, png_bytes: &[u8], size: i32) { + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + loader.write(png_bytes).unwrap_or(()); + loader.close().unwrap_or(()); + if let Some(pixbuf) = loader.pixbuf() { + let icon_size = (size - 10).max(16); + if let Some(scaled) = pixbuf.scale_simple( + icon_size, icon_size, + gdk4::gdk_pixbuf::InterpType::Bilinear, + ) { + let texture = gdk4::Texture::for_pixbuf(&scaled); + let image = gtk4::Image::from_paintable(Some(&texture)); + btn.set_child(Some(&image)); + } + } +} + +// ── Iconos embebidos (con override de skin) ──────────────────────────────── +// Cada función devuelve el icono del skin activo si lo reemplaza, o el PNG +// embebido por defecto. El nombre pasado a `skin::icono` es el archivo que +// un skin debe colocar en `~/.gradio/data/skins//iconos/`. +macro_rules! icono_skin { + ($fn_name:ident, $archivo:literal, $ruta:literal) => { + #[allow(dead_code)] + fn $fn_name() -> Vec { + skin::icono($archivo, include_bytes!($ruta)) + } + }; +} + +icono_skin!(icon_proc_off, "processor-off.png", "../assets/processor-off.png"); +icono_skin!(icon_proc_on, "processor-on.png", "../assets/processor-on.png"); +icono_skin!(icon_proc_cfg, "processor-config.png", "../assets/processor-config.png"); + +icono_skin!(icon_queue_up, "up.png", "../assets/up.png"); +icono_skin!(icon_queue_down, "down.png", "../assets/down.png"); +icono_skin!(icon_queue_trash, "trash.png", "../assets/trash.png"); +icono_skin!(icon_play_off, "play-off.png", "../assets/play-off.png"); +icono_skin!(icon_play_on, "play-on.png", "../assets/play-on.png"); +icono_skin!(icon_pausa, "pausa.png", "../assets/pausa.png"); +icono_skin!(icon_stop, "stop.png", "../assets/stop.png"); +icono_skin!(icon_repetir, "repetir.png", "../assets/repetir.png"); +icono_skin!(icon_siguiente, "siguiente.png", "../assets/siguiente.png"); +icono_skin!(icon_al_final, "detener-siguiente.png", "../assets/detener-siguiente.png"); +icono_skin!(icon_loop_off, "24-Lopp-off.png", "../assets/24-Lopp-off.png"); +icono_skin!(icon_loop_on, "24-Lopp-on.png", "../assets/24-Lopp-on.png"); +icono_skin!(icon_hora_off, "hora-off.png", "../assets/hora-off.png"); +icono_skin!(icon_hora_on, "hora-on.png", "../assets/hora-on.png"); +icono_skin!(icon_fadeout_off, "fadeout-off.png", "../assets/fadeout-off.png"); +icono_skin!(icon_fadeout_on, "fadeout-on.png", "../assets/fadeout-on.png"); +icono_skin!(icon_rec_off, "rec-off.png", "../assets/rec-off.png"); +icono_skin!(icon_rec_on, "rec-on.png", "../assets/rec-on.png"); +icono_skin!(icon_pisador, "sello.png", "../assets/sello.png"); +icono_skin!(icon_robot, "robot.png", "../assets/robot.png"); +icono_skin!(icon_vaciar, "vaciar.png", "../assets/vaciar.png"); +icono_skin!(icon_busqueda, "busqueda.png", "../assets/busqueda.png"); +icono_skin!(icon_gr_on, "GR-On.png", "../assets/GR-On.png"); +icono_skin!(icon_gr_off, "GR-Off.png", "../assets/GR-Off.png"); +icono_skin!(icon_playlist, "playlist.png", "../assets/playlist.png"); +icono_skin!(icon_gradio, "gradio.png", "../assets/gradio.png"); +icono_skin!(icon_pautaje, "pautaje48x48.png", "../assets/pautaje48x48.png"); +icono_skin!(icon_parrilla, "parrilla48x48.png", "../assets/parrilla48x48.png"); +icono_skin!(icon_playlist48, "playlist48x48.png", "../assets/playlist48x48.png"); +icono_skin!(icon_botonera, "botonera48x48.png", "../assets/botonera48x48.png"); +icono_skin!(icon_visor, "visor48x48.png", "../assets/visor48x48.png"); +icono_skin!(icon_grabar, "grabar48x48.png", "../assets/grabar48x48.png"); +icono_skin!(icon_config, "config48x48.png", "../assets/config48x48.png"); +icono_skin!(icon_reportes, "reportes.png", "../assets/reportes.png"); + +/// Interrumpe el pautaje en curso: apaga pipeline_comm, activa abort_pautaje. +/// La función async del pautaje detecta el flag y no llama start_next_track. +fn interrupt_pautaje(state: &SharedState) { + let mut st = state.lock().unwrap(); + st.abort_pautaje = true; + st.stream_skip = true; + if let Some(p) = st.pipeline_comm.take() { + // Flush del bus para desbloquear wait_pipeline_eos inmediatamente + if let Some(bus) = p.bus() { bus.set_flushing(true); } + shutdown_pipeline(p); + } + st.commercials_playing = false; + st.eventos_playing = false; +} + +/// Muestra un diálogo de confirmación cuando el usuario arrastra un audio al deck +/// mientras se reproducen comerciales o eventos. Si confirma, llama on_confirm. +fn show_confirm_interrupt_dialog( + parent_widget: &impl gtk4::prelude::IsA, + on_confirm: impl Fn() + 'static, +) { + let dialog = gtk4::Window::new(); + dialog.set_title(Some("Comerciales en curso")); + dialog.set_modal(true); + dialog.set_default_size(380, -1); + if let Some(root) = parent_widget.root() { + if let Ok(win) = root.downcast::() { + dialog.set_transient_for(Some(&win)); + } + } + let vbox = GtkBox::new(Orientation::Vertical, 12); + vbox.set_margin_top(20); vbox.set_margin_bottom(16); + vbox.set_margin_start(20); vbox.set_margin_end(20); + let lbl = Label::new(Some(i18n::tr("dlg.confirm_interrumpir"))); + lbl.set_wrap(true); + vbox.append(&lbl); + let btn_box = GtkBox::new(Orientation::Horizontal, 8); + btn_box.set_halign(gtk4::Align::End); + let btn_cancel = Button::with_label(i18n::tr("btn.cancel")); + let btn_interrupt = Button::with_label(i18n::tr("btn.interrupt")); + btn_interrupt.add_css_class("control-btn"); + btn_box.append(&btn_cancel); + btn_box.append(&btn_interrupt); + vbox.append(&btn_box); + dialog.set_child(Some(&vbox)); + + let d = dialog.clone(); + btn_cancel.connect_clicked(move |_| { d.close(); }); + let d = dialog.clone(); + btn_interrupt.connect_clicked(move |_| { + on_confirm(); + d.close(); + }); + dialog.present(); +} + +/// `deck`: `Some(ActiveDeck::A/B)` fija el panel a ese deck (modo Players=3, +/// comportamiento de siempre). `None` = panel dinámico: cada acción +/// (play/pausa/stop/loop/seek) resuelve `st.active_deck` en el momento de +/// ejecutarse, para que el mismo panel "siga" al deck que tenga el control +/// (usado en Players=2/1). +fn build_deck_frame( + label: &str, + state: SharedState, + deck: Option, + title_label: &Label, + time_label: &Label, + total_label: &Label, + seek_scale: &Scale, +) -> (Frame, Button) { + let frame = Frame::new(Some(label)); + frame.add_css_class("deck-frame"); + + let vbox = GtkBox::new(Orientation::Vertical, 6); + vbox.set_margin_top(8); + vbox.set_margin_bottom(8); + vbox.set_margin_start(8); + vbox.set_margin_end(8); + + // ── Título del tema ── + title_label.set_halign(gtk4::Align::Start); + title_label.add_css_class("track-title"); + title_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + vbox.append(title_label); + + // ── Barra de tiempo ── + let time_box = GtkBox::new(Orientation::Horizontal, 6); + time_label.add_css_class("time-label"); + total_label.add_css_class("time-label"); + time_box.append(time_label); + time_box.append(seek_scale); + time_box.append(total_label); + seek_scale.set_hexpand(true); + vbox.append(&time_box); + + // ── Botones de control ── + let btn_box = GtkBox::new(Orientation::Horizontal, 4); + btn_box.set_halign(gtk4::Align::Center); + + let btn_play = icon_button(&icon_play_off(), "Play", 44); + let btn_pause = icon_button(&icon_pausa(), "Pausa", 44); + let btn_repeat = icon_button(&icon_repetir(), "Repetir", 44); + let btn_next_track= icon_button(&icon_siguiente(), "Siguiente tema", 44); + let btn_stop_end = icon_button(&icon_al_final(), "Detener al final",44); + let btn_stop = icon_button(&icon_stop(), "Stop", 44); + let btn_loop = icon_button(&icon_loop_off(), "Loop", 44); + + for btn in [&btn_play, &btn_pause, &btn_repeat, &btn_next_track, &btn_stop_end, &btn_stop, &btn_loop] { + btn.add_css_class("deck-icon-btn"); + btn_box.append(btn); + } + vbox.append(&btn_box); + frame.set_child(Some(&vbox)); + + // ── Señales ── + let s = state.clone(); + btn_play.connect_clicked(move |_| { + let mut st = s.lock().unwrap(); + let d = deck.unwrap_or(st.active_deck); + action_play(&mut st, d); + }); + + let s = state.clone(); + btn_pause.connect_clicked(move |_| { + let mut st = s.lock().unwrap(); + let d = deck.unwrap_or(st.active_deck); + action_pause(&mut st, d); + }); + + let s = state.clone(); + btn_repeat.connect_clicked(move |_| { + let mut st = s.lock().unwrap(); + let d = deck.unwrap_or(st.active_deck); + action_repeat(&mut st, d); + }); + + // Siguiente tema con crossfade + let s = state.clone(); + btn_next_track.connect_clicked(move |_| { + let next_idx = { + let st = s.lock().unwrap(); + if st.playlist.is_empty() { return; } + st.current_index % st.playlist.len() + }; + start_next_track(s.clone(), next_idx); + }); + + let s = state.clone(); + btn_stop_end.connect_clicked(move |_| { + let mut st = s.lock().unwrap(); + st.stop_after_track = !st.stop_after_track; + info!("Stop al final del tema: {}", if st.stop_after_track { "activado" } else { "cancelado" }); + }); + + let s = state.clone(); + btn_stop.connect_clicked(move |_| { + let mut st = s.lock().unwrap(); + let d = deck.unwrap_or(st.active_deck); + action_stop(&mut st, d); + }); + + // Botón Loop: alterna loop_deck_a/b y cambia color del botón + let s = state.clone(); + btn_loop.connect_clicked(move |btn| { + let mut st = s.lock().unwrap(); + let d = deck.unwrap_or(st.active_deck); + let active = match d { + ActiveDeck::A => { st.loop_deck_a = !st.loop_deck_a; st.loop_deck_a } + ActiveDeck::B => { st.loop_deck_b = !st.loop_deck_b; st.loop_deck_b } + }; + if active { + btn.remove_css_class("deck-icon-btn"); + btn.add_css_class("deck-icon-btn-active"); + set_button_icon(btn, &icon_loop_on(), 44); + info!("Deck {:?}: loop activado", d); + } else { + btn.remove_css_class("deck-icon-btn-active"); + btn.add_css_class("deck-icon-btn"); + set_button_icon(btn, &icon_loop_off(), 44); + info!("Deck {:?}: loop desactivado", d); + } + }); + + // Seek manual: connect_change_value se dispara SOLO por acción del usuario + let s = state.clone(); + seek_scale.connect_change_value(move |_scale, _scroll, value| { + let st = s.lock().unwrap(); + let d = deck.unwrap_or(st.active_deck); + let pipeline = match d { + ActiveDeck::A => st.pipeline_a.as_ref(), + ActiveDeck::B => st.pipeline_b.as_ref(), + }; + if let Some(p) = pipeline { + pipeline_seek(p, value); + } + glib::Propagation::Proceed + }); + + // ── DropTarget único: STRING para arrastre interno + URIs de file managers ── + // "playlist-row:N" → reproducir ese tema en ESTE deck con crossfade + // URIs de archivos → insertar al frente y reproducir en ESTE deck + { + let s = state.clone(); + let frame_ref = frame.clone(); + + // Solo STRING - maneja tanto arrastre interno como URIs de Nemo + let drop_target = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY); + { + drop_target.connect_drop(move |_target, value, _x, _y| { + let (pautaje_activo, target_deck) = { + let st = s.lock().unwrap(); + let pautaje = st.commercials_playing || st.eventos_playing; + let deck = match st.active_deck { ActiveDeck::A => ActiveDeck::B, ActiveDeck::B => ActiveDeck::A }; + (pautaje, deck) + }; + + let text = match value.get::() { + Ok(t) => t, + Err(_) => return false, + }; + let text = text.trim().to_string(); + + // Determinar idx (e insertar en playlist si es URI externa) + let idx = if text.starts_with("playlist-row:") { + match text["playlist-row:".len()..].parse::() { + Ok(n) => n, + Err(_) => return false, + } + } else { + let uri = text.lines().next().unwrap_or("").trim().trim_start_matches("file://"); + if uri.is_empty() { return false; } + let path = PathBuf::from(uri); + if !path.exists() { return false; } + let track = make_track_from_path(path); + let playlist_path = home_dir().join(".gradio/data/tmp/playlist4"); + let mut st = s.lock().unwrap(); + st.playlist.insert(0, track); + save_playlist_to_file(&st.playlist, &playlist_path); + st.playlist_version = st.playlist_version.wrapping_add(1); + 0 + }; + + if pautaje_activo { + let s2 = s.clone(); + show_confirm_interrupt_dialog(&frame_ref, move || { + interrupt_pautaje(&s2); + play_track_to_deck(s2.clone(), idx, target_deck); + }); + } else { + play_track_to_deck(s.clone(), idx, target_deck); + } + true + }); + } + frame.add_controller(drop_target); + + // DropTarget para gio::File (archivos desde Nemo - igual que botonera) + let drop_file = DropTarget::new(gtk4::gio::File::static_type(), gdk4::DragAction::COPY); + { + let s = state.clone(); + let frame_ref2 = frame.clone(); + drop_file.connect_drop(move |_target, value, _x, _y| { + let (pautaje_activo, target_deck) = { + let st = s.lock().unwrap(); + let pautaje = st.commercials_playing || st.eventos_playing; + let deck = match st.active_deck { ActiveDeck::A => ActiveDeck::B, ActiveDeck::B => ActiveDeck::A }; + (pautaje, deck) + }; + + if let Ok(file) = value.get::() { + if let Some(path) = file.path() { + if !path.exists() { return false; } + let track = make_track_from_path(path); + let playlist_path = home_dir().join(".gradio/data/tmp/playlist4"); + { + let mut st = s.lock().unwrap(); + st.playlist.insert(0, track); + save_playlist_to_file(&st.playlist, &playlist_path); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + if pautaje_activo { + let s2 = s.clone(); + show_confirm_interrupt_dialog(&frame_ref2, move || { + interrupt_pautaje(&s2); + play_track_to_deck(s2.clone(), 0, target_deck); + }); + } else { + play_track_to_deck(s.clone(), 0, target_deck); + } + return true; + } + } + false + }); + } + frame.add_controller(drop_file); + } + + (frame, btn_stop_end) +} + +// ─── Acciones de playback ──────────────────────────────────────────────────── + +fn action_play(st: &mut AppState, deck: ActiveDeck) { + let pipeline = match deck { + ActiveDeck::A => st.pipeline_a.as_ref(), + ActiveDeck::B => st.pipeline_b.as_ref(), + }; + if let Some(p) = pipeline { + // Si veníamos de Paused, realinear current_track_start con la posición + // real del pipeline. De lo contrario, tras una pausa larga (p.ej. 40 min) + // el detector de drift wall-vs-pos dispararía un crossfade espurio. + let was_paused = match deck { + ActiveDeck::A => st.deck_a_state == PlaybackState::Paused, + ActiveDeck::B => st.deck_b_state == PlaybackState::Paused, + }; + if was_paused { + let pos = pipeline_position(p).max(0.0); + st.current_track_start = std::time::Instant::now() + - std::time::Duration::from_secs_f64(pos); + match deck { + ActiveDeck::A => { + st.silence_since_a = None; + st.stuck_since_a = None; + st.stuck_pos_a = -1.0; + } + ActiveDeck::B => { + st.silence_since_b = None; + st.stuck_since_b = None; + st.stuck_pos_b = -1.0; + } + } + st.vu_last_level_msg = std::time::Instant::now(); + } + let _ = p.set_state(gst::State::Playing); + match deck { + ActiveDeck::A => st.deck_a_state = PlaybackState::Playing, + ActiveDeck::B => st.deck_b_state = PlaybackState::Playing, + } + st.active_deck = deck; + } +} + +fn action_pause(st: &mut AppState, deck: ActiveDeck) { + let pipeline = match deck { + ActiveDeck::A => st.pipeline_a.as_ref(), + ActiveDeck::B => st.pipeline_b.as_ref(), + }; + if let Some(p) = pipeline { + let _ = p.set_state(gst::State::Paused); + match deck { + ActiveDeck::A => st.deck_a_state = PlaybackState::Paused, + ActiveDeck::B => st.deck_b_state = PlaybackState::Paused, + } + } +} + +fn action_repeat(st: &mut AppState, deck: ActiveDeck) { + // Seek al inicio del track activo en ese deck + let pipeline = match deck { + ActiveDeck::A => st.pipeline_a.as_ref(), + ActiveDeck::B => st.pipeline_b.as_ref(), + }; + if let Some(p) = pipeline { + pipeline_seek(p, 0.0); + let _ = p.set_state(gst::State::Playing); + } + // Cancelar cualquier crossfade pendiente para evitar que el timer lance el + // siguiente tema mientras GStreamer actualiza la posición tras el seek (async). + st.crossfade_triggered = false; + st.current_track_start = std::time::Instant::now(); +} + +fn action_stop(st: &mut AppState, deck: ActiveDeck) { + // Extraer el pipeline del Option (take) para tener ownership exclusivo + let pipeline_opt = match deck { + ActiveDeck::A => st.pipeline_a.take(), + ActiveDeck::B => st.pipeline_b.take(), + }; + if let Some(p) = pipeline_opt { + match deck { + ActiveDeck::A => st.deck_a_state = PlaybackState::Stopped, + ActiveDeck::B => st.deck_b_state = PlaybackState::Stopped, + } + // Liberar completamente: Null → esperar → cortar bus → drop + shutdown_pipeline(p); + info!("Deck {:?}: pipeline liberado", deck); + } + // El slot ya quedó en None por el .take() — no hay pipeline huérfano +} + +/// Carga un track en el deck indicado y devuelve si fue exitoso +fn load_track_to_deck(st: &mut AppState, deck: ActiveDeck, track_idx: usize) -> bool { + if track_idx >= st.playlist.len() { + return false; + } + let uri = path_to_uri(&st.playlist[track_idx].path); + // Validar que la URI no sea vacía o inválida + if uri == "file://" || uri.is_empty() { + warn!("Deck {:?}: ruta vacía en playlist[{}], saltando", deck, track_idx); + return false; + } + let vol = st.upvol; + let title = st.playlist[track_idx].title.clone(); + + // Liberar pipeline previo completamente (take + Null + drop) + // action_stop hace .take() así que el slot queda en None antes de crear el nuevo + action_stop(st, deck); + + // Verificar que el slot esté vacío antes de proceder + let slot_empty = match deck { + ActiveDeck::A => st.pipeline_a.is_none(), + ActiveDeck::B => st.pipeline_b.is_none(), + }; + if !slot_empty { + error!("Deck {:?}: slot no quedó vacío después de stop, abortando carga", deck); + return false; + } + + info!("Deck {:?}: cargando '{}' desde {}", deck, title, uri); + match build_pipeline(&uri, vol, None) { + Ok(p) => { + match deck { + ActiveDeck::A => st.pipeline_a = Some(p), + ActiveDeck::B => st.pipeline_b = Some(p), + } + info!("Deck {:?}: pipeline creado OK", deck); + true + } + Err(e) => { + error!("Deck {:?}: error al crear pipeline: {}", deck, e); + false + } + } +} + +/// Inicia reproducción de un track con lógica de hora, comerciales, eventos y crossfade +fn start_next_track(state: SharedState, next_index: usize) { + let home = home_dir(); + let comerciales_path = home.join(".gradio/data/tmp/comercialeslist4"); + let eventos_path = home.join(".gradio/data/tmp/eventos-esperalist"); + let playlist_path = home.join(".gradio/data/tmp/playlist4"); + + // 0. Verificar si el próximo ítem es "Hora" + { + let st = state.lock().unwrap(); + if let Some(track) = st.playlist.get(next_index) { + let stem = track.path.file_stem() + .and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); + if stem == "hora" || track.path.to_str().unwrap_or("") == "Hora" { + // Sacar el ítem "Hora" de la lista y reproducir hora del sistema, + // luego continuar con el siguiente track + drop(st); + remove_first_line_from_file(&playlist_path); + { + let mut st2 = state.lock().unwrap(); + if next_index < st2.playlist.len() { + st2.playlist.remove(next_index); + st2.playlist_version = st2.playlist_version.wrapping_add(1); + } + } + let state_h = state.clone(); + let after_idx = next_index; // mismo índice: la lista se desplazó + glib::spawn_future_local(async move { + play_hora(&state_h).await; + start_next_track(state_h, after_idx); + }); + return; + } + } + } + + // 1. Verificar comerciales y/o eventos en espera + let commercials = load_commercials(&comerciales_path); + let eventos = load_eventos(&eventos_path); + + if !commercials.is_empty() || !eventos.is_empty() { + let state_cl = state.clone(); + glib::spawn_future_local(async move { + play_commercials_then_track(state_cl, commercials, eventos, next_index).await; + }); + } else { + // Sin comerciales: verificar si el track es un stream de internet + let (track_path, track_dur, track_title) = { + let st = state.lock().unwrap(); + if let Some(t) = st.playlist.get(next_index) { + (t.path.clone(), t.duration_secs, t.title.clone()) + } else { + return; + } + }; + + let uri = path_to_uri(&track_path); + let is_stream = uri.starts_with("http://") || uri.starts_with("https://"); + + if is_stream { + // ── Stream de internet: reproducir como pipeline_comm con fade ── + // No puede ir en deck A/B porque nunca manda EOS. + remove_first_line_from_file(&home.join(".gradio/data/tmp/playlist4")); + let state_s = state.clone(); + glib::spawn_future_local(async move { + let upvol = state_s.lock().unwrap().upvol; + let crossfade = state_s.lock().unwrap().crossfade_secs; + let dur = track_dur; + + // Actualizar título en UI + { + let mut st = state_s.lock().unwrap(); + st.current_title = track_title.clone(); + st.current_index = next_index; + } + + // Fadeout del deck activo + fadeout_active_deck(&state_s, crossfade).await; + + // Reproducir stream con tiempo límite (dur_secs) o 60s si dur=0 + let limit = if dur > 0.0 { dur } else { 60.0 }; + { state_s.lock().unwrap().stream_skip = false; } + match build_pipeline(&uri, upvol, None) { + Ok(p) => { + let mut p = p; + let _ = p.set_state(gst::State::Playing); + // Esperar hasta 3s a que conecte + let mut ready = false; + for _ in 0..30 { + glib::timeout_future(Duration::from_millis(100)).await; + if let Some(bus) = p.bus() { + while let Some(msg) = bus.timed_pop(gst::ClockTime::ZERO) { + use gst::MessageView; + match msg.view() { + MessageView::StateChanged(sc) => { + if sc.current() == gst::State::Playing { ready = true; } + } + MessageView::Error(_) => { ready = false; break; } + _ => {} + } + } + } + if ready { break; } + } + if ready { + { + let mut st = state_s.lock().unwrap(); + st.pipeline_comm = Some(p.clone()); + st.comm_title = track_title.clone(); + } + attach_level_watch(&p, state_s.clone()); + // Stream con tiempo límite y reconexión automática en caso de caída + let ticks_total = (limit * 5.0) as u64; + let mut ticks_done = 0u64; + let mut reconnect_count = 0u32; + let mut reconnect_elapsed = 0.0f64; + 'stream_pl: loop { + while ticks_done < ticks_total { + glib::timeout_future(Duration::from_millis(200)).await; + ticks_done += 1; + if state_s.lock().unwrap().stream_skip { break 'stream_pl; } + let had_error = p.bus().map_or(false, |bus| { + let mut err = false; + while let Some(msg) = bus.pop() { + if let gst::MessageView::Error(e) = msg.view() { + warn!("Stream playlist caído: {}", e.error()); + err = true; + } + } + err + }); + if had_error { + if reconnect_count >= 10 || reconnect_elapsed >= 20.0 { + warn!("Stream playlist: máximo reconexiones alcanzado ({}): {}", reconnect_count, uri); + break 'stream_pl; + } + let _ = p.set_state(gst::State::Null); + { state_s.lock().unwrap().pipeline_comm = None; } + glib::timeout_future(Duration::from_secs(2)).await; + ticks_done = ticks_done.saturating_add(10); + reconnect_elapsed += 2.0; + reconnect_count += 1; + warn!("Stream playlist: reconectando intento {}/10: {}", reconnect_count, uri); + match build_pipeline(&uri, upvol, None) { + Ok(new_p) => { + let _ = new_p.set_state(gst::State::Playing); + let mut rdy = false; + for _ in 0..15 { + glib::timeout_future(Duration::from_millis(200)).await; + ticks_done = ticks_done.saturating_add(1); + if let Some(bus) = new_p.bus() { + while let Some(msg) = bus.pop() { + use gst::MessageView; + match msg.view() { + MessageView::StateChanged(sc) if sc.current() == gst::State::Playing => { rdy = true; } + _ => {} + } + } + } + if rdy { break; } + } + if rdy { + attach_level_watch(&new_p, state_s.clone()); + { state_s.lock().unwrap().pipeline_comm = Some(new_p.clone()); } + p = new_p; + info!("Stream playlist reconectado (intento {}): {}", reconnect_count, uri); + } else { + warn!("Stream playlist no disponible en reconexión {}: {}", reconnect_count, uri); + let _ = new_p.set_state(gst::State::Null); + break 'stream_pl; + } + } + Err(e) => { error!("Stream playlist reconexión error: {}", e); break 'stream_pl; } + } + } + } + break 'stream_pl; + } + let taken = state_s.lock().unwrap().pipeline_comm.take(); + if let Some(p2) = taken { shutdown_pipeline(p2); } + } else { + warn!("Stream playlist no disponible: {}", uri); + shutdown_pipeline(p); + } + } + Err(e) => error!("Stream playlist error: {}", e), + } + + // Avanzar al siguiente track + let next = { + let st = state_s.lock().unwrap(); + if st.playlist.is_empty() { return; } + (next_index) % st.playlist.len() + }; + start_next_track(state_s, next); + }); + return; + } + + // ── Audio local: crossfade normal entre decks ── + remove_first_line_from_file(&home.join(".gradio/data/tmp/playlist4")); + + let mut st = state.lock().unwrap(); + let upvol = st.upvol; + let crossfade = st.crossfade_secs; + + let next_deck = if st.active_deck == ActiveDeck::A { + ActiveDeck::B + } else { + ActiveDeck::A + }; + + // Loguear el track saliente con el tiempo real que estuvo al aire + let outgoing_track_path = st.current_track_path.clone(); + { + let outgoing_path = &outgoing_track_path; + if *outgoing_path != PathBuf::new() { + // Obtener posición real del pipeline saliente + let real_secs = { + let pipe = match st.active_deck { + ActiveDeck::A => st.pipeline_a.as_ref(), + ActiveDeck::B => st.pipeline_b.as_ref(), + }; + pipe.map(|p| pipeline_position(p)).unwrap_or(0.0) + }; + if real_secs > 0.5 { + log_parrilla(outgoing_path, real_secs); + log_historico(outgoing_path); + } + } + } + st.current_index = next_index; + st.crossfade_triggered = false; // nuevo track: permitir crossfade automático + // Registrar path e inicio del nuevo track + let (new_title, new_path) = st.playlist.get(next_index) + .map(|t| (t.title.clone(), t.path.clone())) + .unwrap_or_default(); + if !new_title.is_empty() { + st.current_title = new_title; + st.current_track_path = new_path; + st.current_track_start = std::time::Instant::now(); + } + let loaded = load_track_to_deck(&mut st, next_deck, next_index); + if !loaded { + // Track inválido (ruta vacía/inexistente): saltar al siguiente + warn!("start_next_track: track[{}] no pudo cargarse, saltando", next_index); + if next_index < st.playlist.len() { + st.playlist.remove(next_index); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + let skip_idx = next_index.min(st.playlist.len().saturating_sub(1)); + drop(st); + start_next_track(state, skip_idx); + return; + } + + // Eliminar el track de la lista en memoria (el archivo ya se borró del disco arriba) + if next_index < st.playlist.len() { + st.playlist.remove(next_index); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + st.current_index = next_index.min(st.playlist.len().saturating_sub(1)); + + let pipe_out = match st.active_deck { + ActiveDeck::A => st.pipeline_a.clone(), + ActiveDeck::B => st.pipeline_b.clone(), + }; + let pipe_in = match next_deck { + ActiveDeck::A => st.pipeline_a.clone(), + ActiveDeck::B => st.pipeline_b.clone(), + }; + + let out_deck_for_fade = st.active_deck; + st.active_deck = next_deck; + + // ── Pisador automático ──────────────────────────────────────────────── + // Clonar path antes del borrow mutable (borrow checker) + let track_path_for_pisador = st.current_track_path.clone(); + let do_pisador = should_play_auto_pisador(&mut st, &track_path_for_pisador); + let pisador_dir_auto = if do_pisador { + let cfg = read_gradio_config(); + let hora_dir = pisador_dir_hora_actual(); + if !hora_dir.is_empty() { hora_dir } else { cfg.pisador_dir.clone() } + } else { + String::new() + }; + drop(st); + + // Lanzar pisador en background después del crossfade + if !pisador_dir_auto.is_empty() { + let state_pis = state.clone(); + glib::spawn_future_local(async move { + // Esperar a que arranque el nuevo deck antes de pisar + glib::timeout_future(Duration::from_millis(800)).await; + play_pisador_from_dir(&state_pis, &pisador_dir_auto).await; + }); + } + + match (pipe_out, pipe_in) { + (Some(pout), Some(pin)) => { + // Si el track saliente es demasiado corto para el crossfade, o si el + // saliente/entrante está en una "carpeta que no se funde" (ej. locuciones + // de IA), arranque directo para no cortar inicio ni final. + let dur_out = pipeline_duration(&pout); + let sin_fundir = fundido_omitido(&read_gradio_config(), &outgoing_track_path, &track_path_for_pisador); + if sin_fundir || (dur_out > 0.0 && dur_out <= crossfade + 1.0) { + // Arranque directo: sin crossfade + set_pipeline_volume(&pin, upvol); + let _ = pin.set_state(gst::State::Playing); + attach_level_watch(&pin, state.clone()); + { + let mut st = state.lock().unwrap(); + match out_deck_for_fade { + ActiveDeck::A => { st.pipeline_a = None; } + ActiveDeck::B => { st.pipeline_b = None; } + } + match st.active_deck { + ActiveDeck::A => st.deck_a_state = PlaybackState::Playing, + ActiveDeck::B => st.deck_b_state = PlaybackState::Playing, + } + } + shutdown_pipeline(pout); + } else { + // Crossfade: do_crossfade arranca pipe_in en vol=0 internamente + attach_level_watch(&pin, state.clone()); + do_crossfade(pout, pin, crossfade, upvol, state.clone(), out_deck_for_fade); + } + } + (None, Some(pin)) => { + set_pipeline_volume(&pin, upvol); + let _ = pin.set_state(gst::State::Playing); + attach_level_watch(&pin, state.clone()); + { + let mut st = state.lock().unwrap(); + match st.active_deck { + ActiveDeck::A => st.deck_a_state = PlaybackState::Playing, + ActiveDeck::B => st.deck_b_state = PlaybackState::Playing, + } + } + } + _ => { + error!("No se pudo iniciar reproducción: pipeline no disponible"); + } + } + } +} + +/// Reproduce un track específico hacia un deck destino con crossfade. +/// Borra el tema de playlist[idx] (ya se está reproduciendo). +/// Usado por doble-click y drag-a-deck. +fn play_track_to_deck(state: SharedState, idx: usize, target_deck: ActiveDeck) { + let home = home_dir(); + let playlist_path = home.join(".gradio/data/tmp/playlist4"); + + // Leer datos del track + let (track_path, _track_dur, crossfade, upvol) = { + let st = state.lock().unwrap(); + if idx >= st.playlist.len() { return; } + ( + st.playlist[idx].path.clone(), + st.playlist[idx].duration_secs, + st.crossfade_secs, + st.upvol, + ) + }; + + let outgoing_track_path; + let (pipe_out, pipe_in) = { + let mut st = state.lock().unwrap(); + + // Loguear el track saliente con tiempo real antes de avanzar + outgoing_track_path = st.current_track_path.clone(); + { + let outgoing_path = &outgoing_track_path; + if *outgoing_path != PathBuf::new() { + let real_secs = { + let pipe = match st.active_deck { + ActiveDeck::A => st.pipeline_a.as_ref(), + ActiveDeck::B => st.pipeline_b.as_ref(), + }; + pipe.map(|p| pipeline_position(p)).unwrap_or(0.0) + }; + if real_secs > 0.5 { + log_parrilla(outgoing_path, real_secs); + } + } + } + + // Guardar el título ANTES de borrar (para que la UI lo muestre correctamente) + let playing_title = if idx < st.playlist.len() { + st.playlist[idx].title.clone() + } else { + String::new() + }; + + // Cargar el track en el deck destino (limpia el flag de "detenido tras tema") + match target_deck { + ActiveDeck::A => st.stopped_after_a = false, + ActiveDeck::B => st.stopped_after_b = false, + } + load_track_to_deck(&mut st, target_deck, idx); + + let pipe_out = match st.active_deck { + ActiveDeck::A => st.pipeline_a.clone(), + ActiveDeck::B => st.pipeline_b.clone(), + }; + let pipe_in = match target_deck { + ActiveDeck::A => st.pipeline_a.clone(), + ActiveDeck::B => st.pipeline_b.clone(), + }; + + // Borrar el tema de la playlist en memoria y en disco + if idx < st.playlist.len() { + st.playlist.remove(idx); + } + // current_index apunta al siguiente en lista; el título real va en current_title + st.current_index = idx.min(st.playlist.len().saturating_sub(1)); + st.current_title = playing_title; + st.current_track_path = track_path.clone(); + st.current_track_start = std::time::Instant::now(); + save_playlist_to_file(&st.playlist, &playlist_path); + st.playlist_version = st.playlist_version.wrapping_add(1); + + // El deck destino pasa a ser el activo + st.active_deck = target_deck; + + (pipe_out, pipe_in) + }; + + // El deck saliente es el opuesto al target + let out_deck = if target_deck == ActiveDeck::A { ActiveDeck::B } else { ActiveDeck::A }; + // Hacer crossfade + match (pipe_out, pipe_in) { + (Some(pout), Some(pin)) => { + let dur_out = pipeline_duration(&pout); + let sin_fundir = fundido_omitido(&read_gradio_config(), &outgoing_track_path, &track_path); + if sin_fundir || (dur_out > 0.0 && dur_out <= crossfade + 1.0) { + set_pipeline_volume(&pin, upvol); + let _ = pin.set_state(gst::State::Playing); + attach_level_watch(&pin, state.clone()); + { + let mut st = state.lock().unwrap(); + match out_deck { + ActiveDeck::A => { st.pipeline_a = None; } + ActiveDeck::B => { st.pipeline_b = None; } + } + match st.active_deck { + ActiveDeck::A => st.deck_a_state = PlaybackState::Playing, + ActiveDeck::B => st.deck_b_state = PlaybackState::Playing, + } + } + shutdown_pipeline(pout); + } else { + attach_level_watch(&pin, state.clone()); + do_crossfade(pout, pin, crossfade, upvol, state.clone(), out_deck); + } + } + (None, Some(pin)) => { + // Primer track, arranque directo + set_pipeline_volume(&pin, upvol); + let _ = pin.set_state(gst::State::Playing); + attach_level_watch(&pin, state.clone()); + { + let mut st = state.lock().unwrap(); + match st.active_deck { + ActiveDeck::A => st.deck_a_state = PlaybackState::Playing, + ActiveDeck::B => st.deck_b_state = PlaybackState::Playing, + } + } + } + _ => error!("play_track_to_deck: pipeline no disponible"), + } +} + +/// Crossfade: fade out pipeline_out, fade in pipeline_in durante `secs` segundos +fn do_crossfade( + pipe_out: gst::Pipeline, + pipe_in: gst::Pipeline, + secs: f64, + target_vol: f64, + state: SharedState, + out_deck: ActiveDeck, +) { + let steps = (secs * 20.0).max(1.0) as u32; + let step_vol = target_vol / steps as f64; + let counter = Arc::new(Mutex::new(0u32)); + let pipe_out_opt = Arc::new(Mutex::new(Some(pipe_out))); + + // Crear token de cancelación único para este crossfade. + // Si llega un nuevo crossfade antes de que este termine, el token anterior + // se reemplaza en AppState y este timer se detiene en el siguiente tick. + let cancel = Arc::new(AtomicBool::new(false)); + { + let mut st = state.lock().unwrap(); + // Cancelar cualquier crossfade previo + st.crossfade_cancel.store(true, Ordering::Relaxed); + st.crossfade_cancel = cancel.clone(); + } + + // Poner pipe_in en Playing con vol=0 aquí dentro — garantiza orden correcto + set_pipeline_volume(&pipe_in, 0.0); + let _ = pipe_in.set_state(gst::State::Playing); + { + let mut st = state.lock().unwrap(); + match st.active_deck { + ActiveDeck::A => st.deck_a_state = PlaybackState::Playing, + ActiveDeck::B => st.deck_b_state = PlaybackState::Playing, + } + } + + timeout_add_local(Duration::from_millis(50), move || { + // Si fue cancelado por un crossfade más nuevo, salir limpiamente + if cancel.load(Ordering::Relaxed) { + // Shutdown inmediato del pipe saliente: ya no es nuestro + if let Some(p) = pipe_out_opt.lock().unwrap().take() { + // Solo limpiar el slot si aún contiene ESTE pipeline saliente. + // Un crossfade más nuevo puede haber cargado ya un pipeline entrante + // en ese mismo slot; limpiarlo sin verificar lo mataría en PLAYING. + { + let mut st = state.lock().unwrap(); + let same = match out_deck { + ActiveDeck::A => st.pipeline_a.as_ref().map_or(false, |x| x == &p), + ActiveDeck::B => st.pipeline_b.as_ref().map_or(false, |x| x == &p), + }; + if same { + match out_deck { + ActiveDeck::A => { st.pipeline_a = None; } + ActiveDeck::B => { st.pipeline_b = None; } + } + } + } + shutdown_pipeline(p); + } + return glib::ControlFlow::Break; + } + + let mut c = counter.lock().unwrap(); + *c += 1; + + let fade_out_vol = (target_vol - step_vol * (*c) as f64).max(0.0); + let fade_in_vol = (step_vol * (*c) as f64).min(target_vol); + + if let Some(p) = pipe_out_opt.lock().unwrap().as_ref() { + set_pipeline_volume(p, fade_out_vol); + } + set_pipeline_volume(&pipe_in, fade_in_vol); + + if *c >= steps { + // Limpiar slot ANTES del shutdown, solo si aún es nuestro pipeline saliente + if let Some(p) = pipe_out_opt.lock().unwrap().as_ref() { + let mut st = state.lock().unwrap(); + let same = match out_deck { + ActiveDeck::A => st.pipeline_a.as_ref().map_or(false, |x| x == p), + ActiveDeck::B => st.pipeline_b.as_ref().map_or(false, |x| x == p), + }; + if same { + match out_deck { + ActiveDeck::A => { st.pipeline_a = None; } + ActiveDeck::B => { st.pipeline_b = None; } + } + } + } + if let Some(p) = pipe_out_opt.lock().unwrap().take() { + shutdown_pipeline(p); + } + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } + }); +} + +/// Devuelve la lista de archivos de audio que representan la hora actual. +/// - Hora exacta (minuto==0): [HRSXX_O.mp3] +/// - Resto: [HRSXX.mp3, MINYY.mp3] +fn hora_audio_paths(time_dir: &std::path::Path) -> Vec { + let now = Local::now(); + let h = now.hour(); // 0-23 + let m = now.minute(); // 0-59 + if m == 0 { + // Hora exacta + let f = time_dir.join(format!("HRS{:02}_O.mp3", h)); + if f.exists() { vec![f] } else { vec![] } + } else { + let fh = time_dir.join(format!("HRS{:02}.mp3", h)); + let fm = time_dir.join(format!("MIN{:02}.mp3", m)); + let mut v = Vec::new(); + if fh.exists() { v.push(fh); } + if fm.exists() { v.push(fm); } + v + } +} + +/// Reproduce un pisador aleatorio desde la carpeta configurada en gradio.config. +/// Usa pipeline_comm a volumen 100% independiente del upvol musical. +async fn play_pisador(state: &SharedState, _home: &PathBuf) { + let cfg = read_gradio_config(); + if cfg.pisador_dir.is_empty() { + error!("Pisador: carpeta no configurada en gradio.config"); + return; + } + play_pisador_from_dir(state, &cfg.pisador_dir).await; +} + +/// Núcleo: elige un audio aleatorio de `dir_str` y lo reproduce como pisador. +async fn play_pisador_from_dir(state: &SharedState, dir_str: &str) { + let dir = PathBuf::from(dir_str); + if !dir.exists() { error!("Pisador: directorio no existe: {:?}", dir); return; } + + let entries: Vec = match fs::read_dir(&dir) { + Ok(rd) => rd + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension() + .and_then(|x| x.to_str()) + .map(|ext| matches!(ext.to_lowercase().as_str(), "mp3" | "wav" | "ogg" | "flac" | "m4a")) + .unwrap_or(false)) + .collect(), + Err(e) => { error!("Pisador: error leyendo directorio {:?}: {}", dir, e); return; } + }; + if entries.is_empty() { error!("Pisador: no hay audios en {:?}", dir); return; } + + let chosen = match entries.choose(&mut rand::thread_rng()) { + Some(p) => p.clone(), + None => return, + }; + + let uri = path_to_uri(&chosen); + info!("Pisador: '{}'", chosen.file_name().unwrap_or_default().to_string_lossy()); + + let old = state.lock().unwrap().pipeline_comm.take(); + if let Some(p) = old { shutdown_pipeline(p); } + + let pipeline: gst::Pipeline = match build_pipeline(&uri, 1.0, None) { + Ok(p) => p, + Err(e) => { error!("Pisador: error pipeline: {}", e); return; } + }; + + let title = chosen.file_stem().unwrap_or_default().to_string_lossy().to_string(); + { + let mut st = state.lock().unwrap(); + st.pipeline_comm = Some(pipeline.clone()); + st.comm_title = format!("🎯 {}", title); + } + + let _ = pipeline.set_state(gst::State::Playing); + attach_level_watch_comm(&pipeline, state.clone()); + wait_pipeline_eos(&pipeline, state).await; + + let taken = state.lock().unwrap().pipeline_comm.take(); + if let Some(p) = taken { shutdown_pipeline(p); } +} + +/// Reproduce un archivo específico en el pipeline de comerciales/pisador (sin tocar la playlist). +async fn play_botonera_file(state: &SharedState, ruta: &str) { + let path = PathBuf::from(ruta); + if !path.exists() { + error!("Botonera remota: archivo no existe: {}", ruta); + return; + } + let uri = path_to_uri(&path); + info!("Botonera remota: '{}'", path.file_name().unwrap_or_default().to_string_lossy()); + + // Apagar botonera previa si había otra en curso. + // NO se toca pipeline_comm — el comercial/parrilla sigue reproduciendo sin interrupción. + let old = state.lock().unwrap().pipeline_botonera.take(); + if let Some(p) = old { shutdown_pipeline(p); } + + let upvol = state.lock().unwrap().upvol; + let pipeline = match build_pipeline(&uri, upvol, None) { + Ok(p) => p, + Err(e) => { error!("Botonera remota: error pipeline: {}", e); return; } + }; + + let title = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + { + let mut st = state.lock().unwrap(); + st.pipeline_botonera = Some(pipeline.clone()); + st.comm_title = format!("🎛 {}", title); + } + + let _ = pipeline.set_state(gst::State::Playing); + + // Esperar EOS con loop propio — no depende de abort_pautaje para que los + // comerciales puedan interrumpirse independientemente de la botonera. + let bus = pipeline.bus().unwrap(); + 'eos: loop { + glib::timeout_future(Duration::from_millis(50)).await; + while let Some(msg) = bus.timed_pop(gst::ClockTime::ZERO) { + use gst::MessageView; + match msg.view() { + MessageView::Eos(_) => break 'eos, + MessageView::Error(e) => { + error!("Botonera: pipeline error: {}", e.error()); + break 'eos; + } + _ => {} + } + } + // Si otra botonera arrancó y ya limpió pipeline_botonera, salir + if state.lock().unwrap().pipeline_botonera.is_none() { break 'eos; } + } + + let taken = state.lock().unwrap().pipeline_botonera.take(); + if let Some(p) = taken { shutdown_pipeline(p); } +} + +/// Verifica si se debe tocar pisador automático para el track en `track_path`. +/// Incrementa el contador y dispara si corresponde, respetando exclusiones. +fn should_play_auto_pisador(st: &mut AppState, track_path: &Path) -> bool { + let cfg = read_gradio_config(); + if !cfg.pisador_enabled || cfg.pisador_dir.is_empty() { return false; } + // Verificar si el track actual está en una carpeta excluida + let track_dir = track_path.parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + for excl in &cfg.pisador_exclude { + let excl_norm = excl.trim_end_matches('/'); + let track_norm = track_dir.trim_end_matches('/'); + if track_norm == excl_norm || track_norm.starts_with(&format!("{}/", excl_norm)) { + info!("Pisador: omitido — carpeta excluida: {}", track_dir); + return false; + } + } + st.pisador_track_count += 1; + if st.pisador_track_count >= cfg.pisador_every { + st.pisador_track_count = 0; + return true; + } + false +} + +/// Verifica si `track_path` está dentro de alguna carpeta de `list` (comparación +/// exacta de carpeta o subcarpeta, normalizando la barra final). +fn path_en_lista_carpetas(track_path: &Path, list: &[String]) -> bool { + if list.is_empty() { return false; } + let track_dir = track_path.parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let track_norm = track_dir.trim_end_matches('/'); + list.iter().any(|carpeta| { + let carpeta_norm = carpeta.trim_end_matches('/'); + track_norm == carpeta_norm || track_norm.starts_with(&format!("{}/", carpeta_norm)) + }) +} + +/// Verifica si el crossfade debe omitirse (arranque/salida directa) porque el +/// track saliente o el entrante pertenecen a una "carpeta que no se funde" +/// (ej. locuciones de IA: no queremos cortar su inicio ni su final). +fn fundido_omitido(cfg: &GradioConfig, saliente: &Path, entrante: &Path) -> bool { + if cfg.fundido_exclude.is_empty() { return false; } + let omitido = path_en_lista_carpetas(saliente, &cfg.fundido_exclude) + || path_en_lista_carpetas(entrante, &cfg.fundido_exclude); + if omitido { + info!("Fundido omitido — carpeta sin crossfade: saliente={} entrante={}", + saliente.display(), entrante.display()); + } + omitido +} + +/// Reproduce la hora del sistema sobre la música de forma asíncrona. +/// Baja la música al 80% del upvol para que la hora se escuche 20% más fuerte. +/// Detecta duración real de cada archivo para eliminar el gap entre hora y minuto. +async fn play_hora(state: &SharedState) { + let home = home_dir(); + let time_dir = home.join(".gradio/data/panel/Time"); + let paths = hora_audio_paths(&time_dir); + if paths.is_empty() { + warn!("play_hora: no se encontraron archivos en {}", time_dir.display()); + return; + } + + // Duck: bajar el deck activo al 80% del upvol + let (active_pipe, upvol) = { + let st = state.lock().unwrap(); + let p = match st.active_deck { + ActiveDeck::A => st.pipeline_a.clone(), + ActiveDeck::B => st.pipeline_b.clone(), + }; + (p, st.upvol) + }; + let ducked_vol = upvol * 0.8; + if let Some(ref p) = active_pipe { + set_pipeline_volume(p, ducked_vol); + } + + for path in paths { + let uri = path_to_uri(&path); + let dur = { + let timeout = gst::ClockTime::from_seconds(5); + gst_pbutils::Discoverer::new(timeout) + .ok() + .and_then(|d| d.discover_uri(&uri).ok()) + .map(|i| i.duration().map(|d| d.seconds_f64()).unwrap_or(0.0)) + .unwrap_or(0.0) + }; + let old_pipe = { state.lock().unwrap().pipeline_comm.take() }; + if let Some(p) = old_pipe { shutdown_pipeline(p); } + match build_pipeline(&uri, 1.0, None) { + Ok(p) => { + let _ = p.set_state(gst::State::Playing); + { + let now = Local::now(); + let label = format!("🕐 {:02}:{:02}", now.hour(), now.minute()); + let mut st = state.lock().unwrap(); + st.pipeline_comm = Some(p.clone()); + st.comm_title = label; + } + attach_level_watch_comm(&p, state.clone()); + if dur > 0.1 { + glib::timeout_future(Duration::from_secs_f64(dur)).await; + } else { + wait_pipeline_eos(&p, state).await; + } + let taken = state.lock().unwrap().pipeline_comm.take(); + if let Some(p2) = taken { shutdown_pipeline(p2); } + } + Err(e) => error!("play_hora pipeline error: {}", e), + } + } + + // Restaurar volumen del deck activo + if let Some(ref p) = active_pipe { + set_pipeline_volume(p, upvol); + } +} + +/// Fade out del deck activo en N pasos de 50ms y luego lo detiene. +async fn fadeout_active_deck(state: &SharedState, secs: f64) { + let (pipe, vol) = { + let st = state.lock().unwrap(); + let p = match st.active_deck { + ActiveDeck::A => st.pipeline_a.clone(), + ActiveDeck::B => st.pipeline_b.clone(), + }; + (p, st.upvol) + }; + if let Some(p) = pipe { + let steps = (secs * 20.0).max(1.0) as u32; + for i in 1..=steps { + let v = (vol - vol / steps as f64 * i as f64).max(0.0); + set_pipeline_volume(&p, v); + glib::timeout_future(Duration::from_millis(50)).await; + } + // Limpiar slot ANTES del shutdown: refcount baja a 1 aquí → 0 en shutdown + { + let mut st = state.lock().unwrap(); + match st.active_deck { + ActiveDeck::A => { st.pipeline_a = None; } + ActiveDeck::B => { st.pipeline_b = None; } + } + } + shutdown_pipeline(p); + } else { + // Sin pipeline activo: limpiar slot igualmente + let mut st = state.lock().unwrap(); + match st.active_deck { + ActiveDeck::A => { st.pipeline_a = None; } + ActiveDeck::B => { st.pipeline_b = None; } + } + } +} + +/// Reproduce la lista de eventos emergentes (eventoslist) de forma asíncrona. +/// Flujo: fadeout deck activo → eventos → comerciales → eventos en espera → siguiente tema. +async fn play_eventos_emergentes(state: SharedState) { + let home = home_dir(); + let eventos_path = home.join(".gradio/data/tmp/eventoslist"); + let espera_path = home.join(".gradio/data/tmp/eventos-esperalist"); + let comerc_path = home.join(".gradio/data/tmp/comercialeslist4"); + + // Leer eventos — NO vaciamos de golpe, borramos línea a línea al reproducir + let eventos = load_eventos(&eventos_path); + if eventos.is_empty() { + state.lock().unwrap().eventos_playing = false; + return; + } + + let upvol = state.lock().unwrap().upvol; + let crossfade = state.lock().unwrap().crossfade_secs; + + // 1. Fadeout suave del deck activo + fadeout_active_deck(&state, crossfade).await; + + // Helper interno reutilizado de play_commercials_then_track + async fn play_audio(state: &SharedState, uri: &str, upvol: f64, dur: f64) -> f64 { + let old = { state.lock().unwrap().pipeline_comm.take() }; + if let Some(p) = old { shutdown_pipeline(p); } + let _decoded = percent_decode(uri.trim_start_matches("file://")); + let comm_name = std::path::Path::new(&_decoded) + .file_stem().and_then(|s| s.to_str()).unwrap_or("Audio").to_string(); + match build_pipeline(uri, upvol, None) { + Ok(p) => { + let _ = p.set_state(gst::State::Playing); + { + let mut st = state.lock().unwrap(); + st.pipeline_comm = Some(p.clone()); + st.comm_title = comm_name.clone(); + } + attach_level_watch_comm(&p, state.clone()); + info!("Evento emergente: {}", comm_name); + if dur > 0.0 { + glib::timeout_future(Duration::from_secs_f64(dur + 0.3)).await; + } else { + wait_pipeline_eos(&p, state).await; + } + let real_secs = pipeline_position(&p); + let taken = state.lock().unwrap().pipeline_comm.take(); + if let Some(p2) = taken { shutdown_pipeline(p2); } + real_secs + } + Err(e) => { error!("Evento pipeline error: {}", e); 0.0 } + } + } + + // 2. Reproducir cada evento emergente, borrando línea a línea + for evento in &eventos { + if state.lock().unwrap().abort_pautaje { break; } + if let Some(ref url) = evento.url { + remove_first_line_from_file(&eventos_path); + info!("Stream emergente: {} ({}s)", url, evento.duration_secs); + play_comm_audio_top(&state, url, upvol, evento.duration_secs, true, &mut None).await; + } else { + let ext = evento.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + let stem = evento.path.file_stem() + .and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); + if ext == "gradio" { + // Lista .gradio: reemplazar playlist completa + remove_first_line_from_file(&eventos_path); + apply_gradio_file(&state, &evento.path, None); + } else if stem == "hora" { + remove_first_line_from_file(&eventos_path); + play_hora(&state).await; + } else { + let resolved = if evento.path.is_dir() { + random_file_from_dir(&evento.path) + } else if evento.path.exists() { + Some(evento.path.clone()) + } else { + None + }; + if let Some(file) = resolved { + remove_first_line_from_file(&eventos_path); + let uri = path_to_uri(&file); + let real_secs = play_audio(&state, &uri, upvol, evento.duration_secs).await; + log_evento(&file, if real_secs > 0.5 { real_secs } else { evento.duration_secs }); + } else { + remove_first_line_from_file(&eventos_path); + warn!("Evento emergente no encontrado: {}", evento.path.display()); + } + } + } + } + + // 3. Limpiar pipeline_comm + { + let old = { state.lock().unwrap().pipeline_comm.take() }; + if let Some(p) = old { shutdown_pipeline(p); } + } + + // 4. Comerciales (si hay) — misma lógica que play_commercials_then_track + // pero SIN arrancar el track al final (eso lo hace start_next_track abajo) + let upvol = state.lock().unwrap().upvol; // re-leer por si cambió + let commercials = load_commercials(&comerc_path); + info!("Eventos emergentes: {} comerciales pendientes", commercials.len()); + + if !commercials.is_empty() { + let home2 = home_dir(); + let inicio_dir = home2.join("G Radio/inicio-espacio-pub"); + let fin_dir = home2.join("G Radio/fin-espacio-pub"); + + // Cada comercial — jingle de inicio antes del primero que realmente suene + let mut comerciales_rep: u32 = 0; + for commercial in &commercials { + if let Some(ref url) = commercial.url { + remove_first_line_from_file(&comerc_path); + if comerciales_rep == 0 { + if let Some(f) = random_file_from_dir(&inicio_dir) { + play_comm_audio_top(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await; + } + } + comerciales_rep += 1; + play_comm_audio_top(&state, url, upvol, commercial.duration_secs, true, &mut None).await; + } else { + let ext_c = commercial.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + let stem = commercial.path.file_stem() + .and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); + if ext_c == "gradio" { + // Lista .gradio en comerciales: reemplazar playlist completa + remove_first_line_from_file(&comerc_path); + apply_gradio_file(&state, &commercial.path, None); + continue; + } else if stem == "hora" { + remove_first_line_from_file(&comerc_path); + if comerciales_rep == 0 { + if let Some(f) = random_file_from_dir(&inicio_dir) { + play_comm_audio_top(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await; + } + } + comerciales_rep += 1; + play_hora(&state).await; + } else { + let resolved = if commercial.path.is_dir() { + random_file_from_dir(&commercial.path) + } else if commercial.path.exists() { + Some(commercial.path.clone()) + } else { + None + }; + if let Some(file) = resolved { + remove_first_line_from_file(&comerc_path); + if comerciales_rep == 0 { + if let Some(f) = random_file_from_dir(&inicio_dir) { + play_comm_audio_top(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await; + } + } + comerciales_rep += 1; + let uri = path_to_uri(&file); + let mut real_secs_c: Option = None; + play_comm_audio_top(&state, &uri, upvol, commercial.duration_secs, false, &mut real_secs_c).await; + log_comercial(&file, real_secs_c.unwrap_or(commercial.duration_secs)); + } else { + remove_first_line_from_file(&comerc_path); + warn!("Comercial no encontrado: {}", commercial.path.display()); + } + } + } + } + + // Jingle de fin: solo si al menos uno sonó + if comerciales_rep > 0 { + if let Some(f) = random_file_from_dir(&fin_dir) { + play_comm_audio_top(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await; + } + } + } + + // 5. Eventos en espera (si hay) + let espera = load_eventos(&espera_path); + info!("Eventos emergentes: {} eventos en espera pendientes", espera.len()); + + for evento in &espera { + if let Some(ref url) = evento.url { + remove_first_line_from_file(&espera_path); + play_comm_audio_top(&state, url, upvol, evento.duration_secs, true, &mut None).await; + } else { + let ext_e2 = evento.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + let stem = evento.path.file_stem() + .and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); + if ext_e2 == "gradio" { + // .gradio en eventos-espera: insertar en la posición actual del playlist + remove_first_line_from_file(&espera_path); + let insert_at = state.lock().unwrap().current_index; + apply_gradio_file(&state, &evento.path, Some(insert_at)); + } else if stem == "hora" { + remove_first_line_from_file(&espera_path); + play_hora(&state).await; + } else { + let resolved = if evento.path.is_dir() { + random_file_from_dir(&evento.path) + } else if evento.path.exists() { + Some(evento.path.clone()) + } else { + None + }; + if let Some(file) = resolved { + remove_first_line_from_file(&espera_path); + let uri = path_to_uri(&file); + let mut real_secs_e: Option = None; + play_comm_audio_top(&state, &uri, upvol, evento.duration_secs, false, &mut real_secs_e).await; + log_evento(&file, real_secs_e.unwrap_or(evento.duration_secs)); + } else { + remove_first_line_from_file(&espera_path); + warn!("Evento en espera no encontrado: {}", evento.path.display()); + } + } + } + } + + // Limpiar pipeline_comm final + { + let old = state.lock().unwrap().pipeline_comm.take(); + if let Some(p) = old { shutdown_pipeline(p); } + } + + // 6. Retomar playlist + { + let mut st = state.lock().unwrap(); + if st.abort_pautaje { + st.abort_pautaje = false; + st.eventos_playing = false; + st.commercials_playing = false; + st.crossfade_triggered = false; + return; + } + st.eventos_playing = false; + } + let next_idx = state.lock().unwrap().current_index; + start_next_track(state.clone(), next_idx); +} + +/// Versión top-level de play_comm_audio para usar fuera de play_commercials_then_track. +/// Idéntica lógica pero accesible como función libre async. +async fn play_comm_audio_top(state: &SharedState, uri: &str, upvol: f64, dur_hint: f64, is_stream: bool, real_secs_out: &mut Option) -> bool { + let dur_hint = if is_stream && dur_hint < 0.1 { + warn!("Stream sin duración declarada, usando 60s por defecto: {}", uri); + 60.0 + } else { dur_hint }; + if state.lock().unwrap().abort_pautaje { return false; } + { state.lock().unwrap().stream_skip = false; } + let old = { state.lock().unwrap().pipeline_comm.take() }; + if let Some(p) = old { shutdown_pipeline(p); } + + let _dec = percent_decode(uri.trim_start_matches("file://")); + let comm_name = if is_stream { + uri.to_string() + } else { + std::path::Path::new(&_dec) + .file_stem().and_then(|s| s.to_str()).unwrap_or("Audio").to_string() + }; + + match build_pipeline(uri, upvol, None) { + Ok(p) => { + let mut p = p; + let _ = p.set_state(gst::State::Playing); + + if is_stream { + let mut ready = false; + for _ in 0..30 { + glib::timeout_future(Duration::from_millis(100)).await; + let bus = p.bus().unwrap(); + let mut got_error = false; + while let Some(msg) = bus.pop() { + use gst::MessageView; + match msg.view() { + MessageView::StateChanged(sc) => { + if sc.current() == gst::State::Playing { ready = true; } + } + MessageView::Error(_) => { got_error = true; } + _ => {} + } + } + if ready || got_error { break; } + } + if !ready { + warn!("Stream no disponible: {}", uri); + shutdown_pipeline(p); + return false; + } + } + + { + let mut st = state.lock().unwrap(); + st.pipeline_comm = Some(p.clone()); + st.comm_title = comm_name.clone(); + } + attach_level_watch_comm(&p, state.clone()); + info!("Reproduciendo (top): {}", comm_name); + + if is_stream && dur_hint > 0.0 { + // Stream con tiempo límite y reconexión automática en caso de caída + let ticks_total = (dur_hint * 5.0) as u64; + let mut ticks_done = 0u64; + let mut reconnect_count = 0u32; + let mut reconnect_elapsed = 0.0f64; + 'stream_top: loop { + while ticks_done < ticks_total { + glib::timeout_future(Duration::from_millis(200)).await; + ticks_done += 1; + if state.lock().unwrap().stream_skip { break 'stream_top; } + if state.lock().unwrap().abort_pautaje { break 'stream_top; } + // Detectar caída del stream (error en el bus) + let had_error = p.bus().map_or(false, |bus| { + let mut err = false; + while let Some(msg) = bus.pop() { + if let gst::MessageView::Error(e) = msg.view() { + warn!("Stream caído durante reproducción: {}", e.error()); + err = true; + } + } + err + }); + if had_error { + if reconnect_count >= 10 || reconnect_elapsed >= 20.0 { + warn!("Stream: máximo de reconexiones alcanzado ({}), abandonando: {}", reconnect_count, uri); + break 'stream_top; + } + let _ = p.set_state(gst::State::Null); + { state.lock().unwrap().pipeline_comm = None; } + // Esperar 2 segundos antes de reconectar + glib::timeout_future(Duration::from_secs(2)).await; + ticks_done = ticks_done.saturating_add(10); + reconnect_elapsed += 2.0; + reconnect_count += 1; + warn!("Stream: reconectando intento {}/10: {}", reconnect_count, uri); + match build_pipeline(uri, upvol, None) { + Ok(new_p) => { + let _ = new_p.set_state(gst::State::Playing); + let mut ready = false; + for _ in 0..15 { + glib::timeout_future(Duration::from_millis(200)).await; + ticks_done = ticks_done.saturating_add(1); + if let Some(bus) = new_p.bus() { + while let Some(msg) = bus.pop() { + use gst::MessageView; + match msg.view() { + MessageView::StateChanged(sc) if sc.current() == gst::State::Playing => { ready = true; } + _ => {} + } + } + } + if ready { break; } + } + if ready { + attach_level_watch_comm(&new_p, state.clone()); + { state.lock().unwrap().pipeline_comm = Some(new_p.clone()); } + p = new_p; + info!("Stream reconectado (intento {}): {}", reconnect_count, uri); + } else { + warn!("Stream no disponible en reconexión {}: {}", reconnect_count, uri); + let _ = new_p.set_state(gst::State::Null); + break 'stream_top; + } + } + Err(e) => { error!("Stream reconexión build error: {}", e); break 'stream_top; } + } + } + } + break 'stream_top; + } + } else if dur_hint > 0.0 { + let ticks = ((dur_hint + 0.3) * 5.0) as u64; + for _ in 0..ticks { + glib::timeout_future(Duration::from_millis(200)).await; + if state.lock().unwrap().abort_pautaje { break; } + } + } else { + wait_pipeline_eos(&p, state).await; + } + + let real_secs = pipeline_position(&p); + let taken = state.lock().unwrap().pipeline_comm.take(); + if let Some(p2) = taken { shutdown_pipeline(p2); } + real_secs_out.replace(real_secs); + true + } + Err(e) => { + error!("Pipeline error top ({}): {}", uri, e); + false + } + } +} + +/// Reproduce comerciales y/o eventos en espera de forma asíncrona, luego inicia el track. +/// Orden: jingle-inicio → comerciales → jingle-fin → eventos → track musical. +/// Si no hay comerciales, omite jingles de inicio/fin. +async fn play_commercials_then_track( + state: SharedState, + #[allow(dead_code)] + commercials: Vec, + eventos: Vec, + track_index: usize, +) { + info!("play_commercials_then_track: {} comerciales, {} espera, track_index={}", + commercials.len(), eventos.len(), track_index); + // Bloquear el timer de crossfade mientras gestionamos el corte + { state.lock().unwrap().commercials_playing = true; } + let home = home_dir(); + let inicio_dir = home.join("G Radio/inicio-espacio-pub"); + let fin_dir = home.join("G Radio/fin-espacio-pub"); + + // Determinar deck libre (el que no está activo) + let (_comm_deck, upvol) = { + let st = state.lock().unwrap(); + let deck = if st.active_deck == ActiveDeck::A { + ActiveDeck::B + } else { + ActiveDeck::A + }; + (deck, st.upvol) + }; + + // Helper: reproducir un audio/stream en pipeline_comm y esperar su fin. + // Para URLs: usa dur_hint como límite de tiempo y respeta stream_skip. + // Retorna false si el stream no estaba disponible (para saltar al siguiente). + async fn play_comm_audio(state: &SharedState, uri: &str, upvol: f64, dur_hint: f64, is_stream: bool, real_secs_out: &mut Option) -> bool { + let dur_hint = if is_stream && dur_hint < 0.1 { + warn!("Stream sin duración declarada, usando 60s por defecto: {}", uri); + 60.0 + } else { dur_hint }; + // Abortar inmediatamente si la señal de interrupción está activa + if state.lock().unwrap().abort_pautaje { return false; } + // Resetear señal de skip al inicio de cada audio + { state.lock().unwrap().stream_skip = false; } + + // Apagar cualquier pipeline_comm previo + let old = { state.lock().unwrap().pipeline_comm.take() }; + if let Some(p) = old { shutdown_pipeline(p); } + + // Nombre para mostrar en UI (decodificar %20, etc. del URI de GStreamer) + let _dec2 = percent_decode(uri.trim_start_matches("file://")); + let comm_name = if is_stream { + uri.to_string() + } else { + std::path::Path::new(&_dec2) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Audio") + .to_string() + }; + + match build_pipeline(uri, upvol, None) { + Ok(p) => { + let mut p = p; + let _ = p.set_state(gst::State::Playing); + + // Para streams: esperar hasta 3s a que arranque (PLAYING o ERROR) + if is_stream { + let mut ready = false; + for _ in 0..30 { + glib::timeout_future(Duration::from_millis(100)).await; + let bus = p.bus().unwrap(); + let mut got_error = false; + while let Some(msg) = bus.pop() { + use gst::MessageView; + match msg.view() { + MessageView::StateChanged(sc) => { + if sc.current() == gst::State::Playing { + ready = true; + } + } + MessageView::Error(_) => { got_error = true; } + _ => {} + } + } + if ready || got_error { break; } + } + if !ready { + warn!("Stream no disponible, saltando: {}", uri); + shutdown_pipeline(p); + return false; + } + } + + { + let mut st = state.lock().unwrap(); + st.pipeline_comm = Some(p.clone()); + st.comm_title = comm_name.clone(); + } + attach_level_watch_comm(&p, state.clone()); + info!("Reproduciendo: {}", comm_name); + + if is_stream && dur_hint > 0.0 { + // Stream con tiempo límite y reconexión automática en caso de caída + let ticks_total = (dur_hint * 5.0) as u64; + let mut ticks_done = 0u64; + let mut reconnect_count = 0u32; + let mut reconnect_elapsed = 0.0f64; + 'stream_comm: loop { + while ticks_done < ticks_total { + glib::timeout_future(Duration::from_millis(200)).await; + ticks_done += 1; + if state.lock().unwrap().stream_skip { + info!("Stream interrumpido por usuario: {}", uri); + break 'stream_comm; + } + if state.lock().unwrap().abort_pautaje { break 'stream_comm; } + // Detectar caída del stream (error en el bus) + let had_error = p.bus().map_or(false, |bus| { + let mut err = false; + while let Some(msg) = bus.pop() { + if let gst::MessageView::Error(e) = msg.view() { + warn!("Stream caído durante reproducción: {}", e.error()); + err = true; + } + } + err + }); + if had_error { + if reconnect_count >= 10 || reconnect_elapsed >= 20.0 { + warn!("Stream: máximo de reconexiones alcanzado ({}), abandonando: {}", reconnect_count, uri); + break 'stream_comm; + } + let _ = p.set_state(gst::State::Null); + { state.lock().unwrap().pipeline_comm = None; } + // Esperar 2 segundos antes de reconectar + glib::timeout_future(Duration::from_secs(2)).await; + ticks_done = ticks_done.saturating_add(10); + reconnect_elapsed += 2.0; + reconnect_count += 1; + warn!("Stream: reconectando intento {}/10: {}", reconnect_count, uri); + match build_pipeline(uri, upvol, None) { + Ok(new_p) => { + let _ = new_p.set_state(gst::State::Playing); + let mut ready = false; + for _ in 0..15 { + glib::timeout_future(Duration::from_millis(200)).await; + ticks_done = ticks_done.saturating_add(1); + if let Some(bus) = new_p.bus() { + while let Some(msg) = bus.pop() { + use gst::MessageView; + match msg.view() { + MessageView::StateChanged(sc) if sc.current() == gst::State::Playing => { ready = true; } + _ => {} + } + } + } + if ready { break; } + } + if ready { + attach_level_watch_comm(&new_p, state.clone()); + { state.lock().unwrap().pipeline_comm = Some(new_p.clone()); } + p = new_p; + info!("Stream reconectado (intento {}): {}", reconnect_count, uri); + } else { + warn!("Stream no disponible en reconexión {}: {}", reconnect_count, uri); + let _ = new_p.set_state(gst::State::Null); + break 'stream_comm; + } + } + Err(e) => { error!("Stream reconexión build error: {}", e); break 'stream_comm; } + } + } + } + break 'stream_comm; + } + } else if dur_hint > 0.0 { + let ticks = ((dur_hint + 0.3) * 5.0) as u64; + for _ in 0..ticks { + glib::timeout_future(Duration::from_millis(200)).await; + if state.lock().unwrap().abort_pautaje { break; } + } + } else { + wait_pipeline_eos(&p, state).await; + } + + // Limpiar + let real_secs = pipeline_position(&p); + let taken = state.lock().unwrap().pipeline_comm.take(); + if let Some(p2) = taken { shutdown_pipeline(p2); } + real_secs_out.replace(real_secs); + true + } + Err(e) => { + error!("Pipeline error ({}): {}", uri, e); + false + } + } + } + + // ── Reproducir cada comercial (sin fundido) ── + // Jingle de inicio: solo antes del primer comercial que realmente se reproduzca + let comerciales_path_loop = home.join(".gradio/data/tmp/comercialeslist4"); + let mut comerciales_reproducidos: u32 = 0; + for commercial in &commercials { + if state.lock().unwrap().abort_pautaje { break; } + if let Some(ref url) = commercial.url { + remove_first_line_from_file(&comerciales_path_loop); + if comerciales_reproducidos == 0 { + if let Some(f) = random_file_from_dir(&inicio_dir) { + play_comm_audio(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await; + } + } + comerciales_reproducidos += 1; + info!("Stream comercial: {} ({}s)", url, commercial.duration_secs); + play_comm_audio(&state, url, upvol, commercial.duration_secs, true, &mut None).await; + } else { + let stem = commercial.path.file_stem() + .and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); + if stem == "hora" { + remove_first_line_from_file(&comerciales_path_loop); + // "Hora" cuenta como entrada real de tanda + if comerciales_reproducidos == 0 { + if let Some(f) = random_file_from_dir(&inicio_dir) { + play_comm_audio(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await; + } + } + comerciales_reproducidos += 1; + play_hora(&state).await; + } else { + let resolved = if commercial.path.is_dir() { + random_file_from_dir(&commercial.path) + } else if commercial.path.exists() { + Some(commercial.path.clone()) + } else { + None + }; + if let Some(file) = resolved { + remove_first_line_from_file(&comerciales_path_loop); + if comerciales_reproducidos == 0 { + if let Some(f) = random_file_from_dir(&inicio_dir) { + play_comm_audio(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await; + } + } + comerciales_reproducidos += 1; + let uri = path_to_uri(&file); + let mut real_secs_c: Option = None; + play_comm_audio(&state, &uri, upvol, commercial.duration_secs, false, &mut real_secs_c).await; + log_comercial(&file, real_secs_c.unwrap_or(commercial.duration_secs)); + } else { + remove_first_line_from_file(&comerciales_path_loop); + warn!("Comercial no encontrado: {}", commercial.path.display()); + } + } + } + } + + // ── Audio de fin: solo si al menos un comercial se reprodujo realmente ── + if comerciales_reproducidos > 0 { + if let Some(fin_file) = random_file_from_dir(&fin_dir) { + let uri = path_to_uri(&fin_file); + play_comm_audio(&state, &uri, upvol, 0.0, false, &mut None).await; + } + } + + // No vaciamos comercialeslist4 de golpe — los remove_first_line + // del loop ya eliminaron cada línea al reproducirla. + + // ── Reproducir eventos en espera (sin jingles, sin fundido) ── + let eventos_path = home.join(".gradio/data/tmp/eventos-esperalist"); + for evento in &eventos { + if state.lock().unwrap().abort_pautaje { break; } + if let Some(ref url) = evento.url { + remove_first_line_from_file(&eventos_path); + info!("Stream espera: {} ({}s)", url, evento.duration_secs); + play_comm_audio(&state, url, upvol, evento.duration_secs, true, &mut None).await; + } else { + let stem = evento.path.file_stem() + .and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); + if stem == "hora" { + remove_first_line_from_file(&eventos_path); + play_hora(&state).await; + } else { + let resolved = if evento.path.is_dir() { + random_file_from_dir(&evento.path) + } else if evento.path.exists() { + Some(evento.path.clone()) + } else { + None + }; + if let Some(file) = resolved { + remove_first_line_from_file(&eventos_path); + let uri = path_to_uri(&file); + let mut real_secs_e: Option = None; + play_comm_audio(&state, &uri, upvol, evento.duration_secs, false, &mut real_secs_e).await; + log_evento(&file, real_secs_e.unwrap_or(evento.duration_secs)); + } else { + remove_first_line_from_file(&eventos_path); + warn!("Evento en espera no encontrado: {}", evento.path.display()); + } + } + } + } + + // No vaciamos eventos-esperalist de golpe — los remove_first_line + // del loop ya eliminaron cada línea al reproducirla. + + // Asegurar pipeline_comm vacío + let old = { state.lock().unwrap().pipeline_comm.take() }; + if let Some(p) = old { shutdown_pipeline(p); } + + // ── Liberar el bloqueo y dejar que start_next_track arranque el track ── + // Resetear crossfade_triggered para que el timer opere normalmente + { + let mut st = state.lock().unwrap(); + if st.abort_pautaje { + // El usuario interrumpió el pautaje arrastrando un audio al deck: + // no arrancar el siguiente track, el deck ya está en marcha. + st.abort_pautaje = false; + st.commercials_playing = false; + st.crossfade_triggered = false; + return; + } + st.commercials_playing = false; + st.crossfade_triggered = false; + } + start_next_track(state, track_index); +} + +/// Espera hasta que el pipeline llegue a EOS, error, o se active abort_pautaje. +async fn wait_pipeline_eos(pipeline: &gst::Pipeline, state: &SharedState) { + // Usar timed_pop(0) — no bloqueante — y ceder al main loop entre polls. + // timed_pop con tiempo > 0 bloquea el hilo GTK y congela la UI. + let bus = pipeline.bus().unwrap(); + loop { + glib::timeout_future(Duration::from_millis(50)).await; + // Interrupción por abort_pautaje (drag a deck durante comerciales) + if state.lock().unwrap().abort_pautaje { return; } + // Drena todos los mensajes pendientes sin bloquear + loop { + match bus.timed_pop(gst::ClockTime::ZERO) { + None => break, + Some(msg) => { + use gst::MessageView; + match msg.view() { + MessageView::Eos(_) => return, + MessageView::Error(e) => { + error!("Pipeline EOS-wait error: {}", e.error()); + return; + } + _ => {} + } + } + } + } + } +} + +// ─── Diálogo insertar streaming ───────────────────────────────────────────── + +/// Muestra un diálogo flotante no modal con la ruta completa del track. +/// Click en la ruta la copia al portapapeles. +fn show_path_dialog(parent: >k4::ApplicationWindow, path: &str) { + let dialog = gtk4::Window::new(); + dialog.set_title(Some(i18n::tr("dlg.ruta_archivo"))); + dialog.set_transient_for(Some(parent)); + dialog.set_modal(false); + dialog.set_default_size(560, -1); + dialog.set_resizable(false); + + let vbox = GtkBox::new(Orientation::Vertical, 8); + vbox.set_margin_top(16); + vbox.set_margin_bottom(16); + vbox.set_margin_start(16); + vbox.set_margin_end(16); + + let lbl = Label::new(Some(path)); + lbl.set_wrap(true); + lbl.set_wrap_mode(gtk4::pango::WrapMode::WordChar); + lbl.set_selectable(true); + lbl.set_halign(gtk4::Align::Start); + lbl.add_css_class("status-label"); + + let btn_copy = Button::with_label(i18n::tr("dlg.copiar_ruta")); + btn_copy.add_css_class("global-btn"); + let path_owned = path.to_string(); + let dialog_ref = dialog.clone(); + btn_copy.connect_clicked(move |_| { + if let Some(display) = gdk4::Display::default() { + display.clipboard().set_text(&path_owned); + } + dialog_ref.close(); + }); + + let btn_close = Button::with_label(i18n::tr("btn.close")); + btn_close.add_css_class("global-btn"); + let dialog_ref2 = dialog.clone(); + btn_close.connect_clicked(move |_| { dialog_ref2.close(); }); + + let btn_row = GtkBox::new(Orientation::Horizontal, 8); + btn_row.set_halign(gtk4::Align::End); + btn_row.append(&btn_copy); + btn_row.append(&btn_close); + + vbox.append(&lbl); + vbox.append(&btn_row); + dialog.set_child(Some(&vbox)); + dialog.present(); +} + +fn show_insert_stream_dialog( + parent: &ApplicationWindow, + state: SharedState, + insert_idx: usize, + playlist_path: PathBuf, +) { + // Ventana modal simple con dos campos + let dialog = gtk4::Window::new(); + dialog.set_title(Some(i18n::tr("dlg.ins_stream_title"))); + dialog.set_transient_for(Some(parent)); + dialog.set_modal(true); + dialog.set_default_size(420, 180); + dialog.set_resizable(false); + + let vbox = GtkBox::new(Orientation::Vertical, 12); + vbox.set_margin_top(20); + vbox.set_margin_bottom(20); + vbox.set_margin_start(20); + vbox.set_margin_end(20); + + // Campo URL + let url_box = GtkBox::new(Orientation::Horizontal, 8); + let url_lbl = Label::new(Some(i18n::tr("dlg.url_lbl"))); + url_lbl.set_width_chars(10); + url_lbl.set_xalign(1.0); + let url_entry = Entry::new(); + url_entry.set_hexpand(true); + url_entry.set_placeholder_text(Some("https://stream.example.com/mi-radio")); + url_box.append(&url_lbl); + url_box.append(&url_entry); + + // Campo duración + let dur_box = GtkBox::new(Orientation::Horizontal, 8); + let dur_lbl = Label::new(Some(i18n::tr("dlg.duracion_s"))); + dur_lbl.set_width_chars(10); + dur_lbl.set_xalign(1.0); + let dur_entry = Entry::new(); + dur_entry.set_width_chars(8); + dur_entry.set_placeholder_text(Some("60")); + dur_entry.set_text("60"); + dur_box.append(&dur_lbl); + dur_box.append(&dur_entry); + + // Botones + let btn_box = GtkBox::new(Orientation::Horizontal, 8); + btn_box.set_halign(gtk4::Align::End); + + let btn_cancel = Button::with_label(i18n::tr("btn.cancel")); + let btn_insert = Button::with_label(i18n::tr("dlg.btn_insertar")); + btn_insert.add_css_class("suggested-action"); + + btn_box.append(&btn_cancel); + btn_box.append(&btn_insert); + + vbox.append(&url_box); + vbox.append(&dur_box); + vbox.append(&btn_box); + dialog.set_child(Some(&vbox)); + + // Cancelar + let dlg_cancel = dialog.clone(); + btn_cancel.connect_clicked(move |_| { + dlg_cancel.close(); + }); + + // Insertar + let dlg_insert = dialog.clone(); + let url_e = url_entry.clone(); + let dur_e = dur_entry.clone(); + btn_insert.connect_clicked(move |_| { + let url = url_e.text().to_string(); + let dur: f64 = dur_e.text().to_string().parse().unwrap_or(60.0); + if url.is_empty() { return; } + + let short = url.split('/').last().unwrap_or(&url).to_string(); + let display = if short.is_empty() { url.clone() } else { short }; + let stream_track = Track { + path: PathBuf::from(&url), + duration_secs: dur, + title: format!("📡 {}", display), + }; + { + let mut st = state.lock().unwrap(); + st.playlist.insert(insert_idx, stream_track); + save_playlist_to_file(&st.playlist, &playlist_path); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + dlg_insert.close(); + }); + + // Enter en url_entry también activa insertar + let btn_insert_ref = btn_insert.clone(); + url_entry.connect_activate(move |_| { btn_insert_ref.activate(); }); + let btn_insert_ref2 = btn_insert.clone(); + dur_entry.connect_activate(move |_| { btn_insert_ref2.activate(); }); + + dialog.present(); +} + +// ─── CSS ────────────────────────────────────────────────────────────────────── + +const APP_CSS: &str = r#" +/* ── Fondo general: negro como GRadio ── */ +window { + background-color: #1c1c1c; + color: #e0e0e0; +} + +/* ── Cabecera barra superior ── */ +.header-bar { + background-color: #111111; + border-bottom: 2px solid #3a3a3a; + padding: 6px 12px; + min-height: 56px; +} + +.vu-row { + background-color: #0d0d0d; + padding: 2px 8px; + border-bottom: 1px solid #222222; +} + +.vu-ch-label { + font-family: "monospace"; + font-size: 10px; + color: #888888; + min-width: 10px; +} + +.vu-gradio-label { + font-family: "sans-serif"; + font-size: 16px; + font-weight: bold; + color: #ff2222; + padding-left: 12px; +} + +.vu-pulso-label { + font-family: "sans-serif"; + font-size: 11px; + font-weight: bold; + color: #9933cc; + padding-left: 12px; +} + +.vu-cliente-label { + font-family: "sans-serif"; + font-size: 11px; + color: #00cc88; +} + +/* ── Reloj: verde brillante exacto de GRadio ── */ +.clock-label { + color: #00e676; + font-family: "Liberation Mono", "DejaVu Sans Mono", monospace; + font-size: 33px; + font-weight: bold; +} + +/* ── Barra de estado / nombre del tema en curso ── */ +.now-playing-bar { + background-color: #2a2a2a; + border-bottom: 1px solid #444; + padding: 3px 8px; +} + +.now-playing-label { + color: #00e676; + font-family: "Liberation Mono", monospace; + font-size: 18px; + font-weight: bold; +} + +/* ── Versión bajo el logo ── */ +.version-lbl { + color: #8899bb; + font-size: 9px; + font-style: italic; +} + +/* ── Nombre de la radio (púrpura) ── */ +.station-name-lbl { + color: #cc88ff; + font-size: 15px; + font-weight: bold; + margin-left: 6px; + margin-right: 6px; +} + +/* ── Info de volumen y fundido ── */ +.status-label { + color: #aaaaaa; + font-size: 16px; + font-style: italic; +} + +/* ── Frames de deck A y B ── */ +.deck-frame { + background-color: #252525; + border-radius: 4px; + border: 1px solid #444444; + padding: 2px; +} + +.deck-frame > label { + color: #cccccc; + font-weight: bold; + font-size: 12px; + padding: 2px 6px; + background-color: #333333; +} + +/* ── Título del tema dentro del deck ── */ +.track-title { + color: #ffffff; + font-size: 13px; + font-weight: bold; + margin-bottom: 2px; + background-color: #1a6496; + padding: 3px 6px; + border-radius: 2px; +} + +/* ── Tiempo transcurrido / total ── */ +.time-label { + color: #00e676; + font-family: "Liberation Mono", monospace; + font-size: 12px; + font-weight: bold; + min-width: 58px; +} + +/* ── Barra de progreso (seek) ── */ +scale trough { + background-color: #3a3a3a; + min-height: 8px; + border-radius: 4px; +} + +scale highlight { + background-color: #1a6496; + border-radius: 4px; +} + +scale slider { + background-color: #00aaff; + min-width: 14px; + min-height: 14px; + border-radius: 7px; + border: 2px solid #0077cc; +} + +/* ── Botones de control (Play, Pausa, Stop, etc.) ── */ +.control-btn { + background-color: #e8edf3; + color: #0d1b3e; + border-radius: 6px; + border: 2px solid #1a3a6b; + padding: 4px 8px; + font-size: 11px; + font-weight: bold; + min-width: 60px; +} + +.control-btn:hover { + background-color: #1a3a6b; + color: #ffffff; + border-color: #1a3a6b; +} + +.control-btn:active { + background-color: #0d1b3e; + color: #ffffff; + border-color: #0d1b3e; +} + +/* ── Botones icono de deck (cuadrados, tamaño doble) ── */ +button.deck-icon-btn, +button.deck-icon-btn * { + all: unset; +} +button.deck-icon-btn { + background: #555555; + border-radius: 6px; + border: 2px solid #777777; + padding: 2px; + min-width: 44px; + min-height: 44px; +} + +button.deck-icon-btn:hover { + background: #6a6a6a; + border-color: #aaaaaa; +} + +button.deck-icon-btn:active { + background: #3a3a3a; +} + +/* Loop activo */ +button.deck-icon-btn-active, +button.deck-icon-btn-active * { + all: unset; +} +button.deck-icon-btn-active { + background: #3d1212; + border-radius: 6px; + border: 2px solid #7a3030; + padding: 2px; + min-width: 44px; + min-height: 44px; +} + +button.deck-icon-btn-active:hover { + background: #4d1818; +} + +/* Botón "Detener al final" armado (pendiente de disparar) */ +@keyframes stop-pending-pulse { + 0% { background: #3d1212; border-color: #7a3030; } + 50% { background: #7a1a1a; border-color: #ee4444; } + 100% { background: #3d1212; border-color: #7a3030; } +} +button.deck-icon-btn-stop, +button.deck-icon-btn-stop * { + all: unset; +} +button.deck-icon-btn-stop { + background: #3d1212; + border-radius: 6px; + border: 2px solid #7a3030; + padding: 2px; + min-width: 44px; + min-height: 44px; + animation: stop-pending-pulse 1s ease-in-out infinite; +} +button.deck-icon-btn-stop:hover { + background: #5a1a1a; +} + +/* ── Botones icono barra global (cuadrados medianos) ── */ +button.global-icon-btn, +button.global-icon-btn * { + all: unset; +} +button.global-icon-btn { + background: #555555; + border-radius: 5px; + border: 2px solid #777777; + padding: 2px; + min-width: 36px; + min-height: 36px; +} + +button.global-icon-btn:hover { + background: #6a6a6a; + border-color: #aaaaaa; +} + +button.global-icon-btn:active { + background: #3a3a3a; +} + +button.global-icon-btn:checked { + background: #1a6a1a; + border-color: #2aaa2a; +} + +/* ── Botón Stop General (ícono rojo) ── */ +button.stop-general-btn { background: #5a1a1a; border-color: #8a3333; } +button.stop-general-btn:hover { background: #6a2222; } +button.stop-general-btn:active { background: #8a2222; } + +/* ── Botones externos cuadrados con texto ── */ +.ext-sq-btn { + background-color: #1a2a3a; + color: #4a90d9; + border-radius: 5px; + border: 2px solid #2a5a8a; + padding: 2px 4px; + font-size: 10px; + font-weight: bold; + min-width: 36px; + min-height: 36px; +} + +.ext-sq-btn:hover { + background-color: #2a5a8a; + color: #ffffff; +} + +.ext-sq-btn:active { + background-color: #0e1e2e; + color: #ffffff; +} + +/* ── Botón Duck / micrófono ── */ +.duck-btn { + background-color: #e8edf3; + color: #0d1b3e; + border-radius: 6px; + border: 2px solid #1a3a6b; + padding: 5px 12px; + font-weight: bold; + font-size: 12px; +} + +.duck-btn:checked { + background-color: #1a3a6b; + color: #ffffff; + border-color: #1a3a6b; +} + +.duck-btn:hover { + background-color: #1a3a6b; + color: #ffffff; + border-color: #1a3a6b; +} + +/* ── Frames de las listas (cola playlist / comerciales) ── */ +.list-frame { + background-color: #1c1c1c; + border-radius: 3px; + border: 1px solid #444444; +} + +.list-frame > label { + color: #cccccc; + font-weight: bold; + font-size: 12px; + background-color: #333333; + padding: 2px 6px; +} + +/* ── ListBox de cola ── */ +.queue-list { + background-color: #1c1c1c; +} + +/* Fila normal */ +.queue-list row { + padding: 4px 8px; + border-bottom: 1px solid #2e2e2e; + color: #cccccc; + font-size: 18px; + background-color: #1c1c1c; +} + +/* Fila par: ligeramente más clara, como GRadio */ +.queue-list row:nth-child(even) { + background-color: #242424; +} + +.queue-list row:hover { + background-color: #2a2a2a; +} + +/* ── Menú contextual flotante ── */ +.context-menu-box { + background: #2a2a2a; + border-radius: 6px; + padding: 4px; + border: 1px solid #555; +} +.context-menu-item { + background: transparent; + color: #e0e0e0; + border: none; + border-radius: 4px; + padding: 6px 16px; + font-size: 13px; + min-width: 220px; +} +.context-menu-item:hover { + background: #3d6fa5; + color: white; +} +.context-menu-item-danger { + color: #ff6b6b; +} +.context-menu-item-danger:hover { + background: #8b1a1a; + color: white; +} + +/* Primera fila = próximo a reproducir: resaltado azul GRadio */ +.playing-row { + background-color: #1a6496; + color: #ffffff; + font-weight: bold; +} + +/* Botón numérico de la cola de reproducción */ +button.queue-idx-btn, +button.queue-idx-btn * { + all: unset; +} +button.queue-idx-btn { + background: #1a6496; + color: #ffffff; + font-size: 11px; + font-weight: bold; + min-width: 26px; + min-height: 18px; + padding: 2px 5px; + border-radius: 3px; +} +button.queue-idx-btn:hover { + background: #2980b9; +} +button.queue-idx-btn:active { + background: #145074; +} + +/* Botones de acción inline (subir / bajar / eliminar) en la cola */ +button.queue-action-btn { + background: transparent; + border: none; + border-radius: 3px; + padding: 2px; + min-width: 26px; + min-height: 22px; + opacity: 0.35; +} +button.queue-action-btn:hover { + opacity: 1.0; + background: rgba(100,100,100,0.35); +} +button.queue-action-btn:active { + opacity: 1.0; + background: rgba(100,100,100,0.55); +} + +/* ── Duración por fila del playlist ── */ +label.queue-dur-label { + font-size: 11px; + color: #8a9ab0; + min-width: 42px; + margin-right: 2px; +} + +/* ── Duración total del playlist (cabecera) ── */ +label.playlist-total-dur { + font-size: 11px; + color: #7ab0d4; + font-weight: bold; + margin-right: 6px; +} + +/* ── Botones globales (Iniciar, Siguiente) ── */ +.global-btn { + background-color: #e8edf3; + color: #0d1b3e; + border-radius: 6px; + border: 2px solid #1a3a6b; + padding: 6px 16px; + font-size: 12px; + font-weight: bold; + min-width: 130px; +} + +.global-btn:hover { + background-color: #1a3a6b; + color: #ffffff; + border-color: #1a3a6b; +} + +.global-btn:active { + background-color: #0d1b3e; + color: #ffffff; +} + +/* ── Botones de programas externos ── */ +.ext-btn { + background-color: #1a2a3a; + color: #4a90d9; + border-radius: 6px; + border: 2px solid #2a5a8a; + padding: 6px 12px; + font-size: 11px; + font-weight: bold; +} + +.ext-btn:hover { + background-color: #2a5a8a; + color: #ffffff; +} + +.ext-btn:active { + background-color: #0e1e2e; + color: #ffffff; +} + +/* ── Botón Loop activo ── */ +.loop-btn-on { + background-color: #cc0000; + border: 2px solid #ff4444; + border-radius: 6px; + padding: 6px 12px; + font-size: 11px; + font-weight: bold; +} +.config-label { + color: #e0c040; + font-weight: bold; + font-size: 12px; +} + +.loop-btn-on label { + color: #ffffff; +} +.loop-btn-on:hover { + background-color: #ff2222; +} + +/* ── Cabecera del panel playlist ── */ +.playlist-header { + background-color: #333333; + padding: 2px 6px; + border-bottom: 1px solid #444444; +} +.playlist-header label { + color: #cccccc; + font-weight: bold; + font-size: 12px; +} + +/* ── Botón Automático (refill) — verde=activo, rojo=pausado ── */ +button.refill-auto-btn, +button.refill-auto-btn * { all: unset; } +button.refill-auto-btn { + background: #1a4a1a; + border-radius: 5px; + border: 2px solid #2a7a2a; + padding: 2px; + min-width: 28px; + min-height: 28px; +} +button.refill-auto-btn:hover { background: #2a6a2a; border-color: #3aaa3a; } +button.refill-auto-btn:checked { + background: #5a1a1a; + border-color: #8a3333; +} +button.refill-auto-btn:checked:hover { background: #6a2222; border-color: #aa4444; } + +/* ── Botón Vaciar playlist ── */ +button.vaciar-playlist-btn, +button.vaciar-playlist-btn * { all: unset; } +button.vaciar-playlist-btn { + background: #3a2a1a; + border-radius: 5px; + border: 2px solid #6a5a3a; + padding: 2px; + min-width: 28px; + min-height: 28px; +} +button.vaciar-playlist-btn:hover { background: #5a3a1a; border-color: #9a7a3a; } +button.vaciar-playlist-btn:active { background: #6a2222; border-color: #aa4444; } + +/* ── Tarjetas de tandas de comerciales próximas ── */ +.tanda-card { border-radius: 6px; padding: 4px; margin: 2px; } +.tanda-header { font-weight: bold; font-size: 13px; } +/* background-image: image() es la única forma que el tema oscuro no anula */ +box.tanda-bg-0 { background-image: image(#1e3a5c); color: #bbdefb; border-radius: 4px; } +box.tanda-bg-1 { background-image: image(#1b4332); color: #b7e4c7; border-radius: 4px; } +box.tanda-bg-2 { background-image: image(#7f4f24); color: #ffe8a3; border-radius: 4px; } +box.tanda-bg-3 { background-image: image(#3d1a5c); color: #e9d5ff; border-radius: 4px; } +/* Cola activa (comercialeslist4) — rojo oscuro para distinguir de las futuras */ +box.tanda-bg-active { background-image: image(#5a1a1a); color: #ffcccc; border-radius: 4px; } +box.tanda-bg-0 button, box.tanda-bg-1 button, +box.tanda-bg-2 button, box.tanda-bg-3 button { min-width: 24px; min-height: 24px; padding: 0 4px; } + +/* ── Botón procesador de audio activo ── */ +button.processor-on { background-image: image(#1565c0); color: white; } + +"#; + +// ─── Estructura de carpetas G Radio (primer arranque) ──────────────────────── + +/// Crea la estructura de carpetas `~/G Radio/` y preconfigura `gradio.config` +/// con sus rutas si el archivo de configuración todavía no existe. +fn crear_dirs_g_radio(home: &std::path::Path) { + let base = home.join("G Radio"); + let dirs = [ + "inicio-espacio-pub", + "fin-espacio-pub", + "pisadores", + "Intercultural", + ]; + for d in &dirs { + let path = base.join(d); + if let Err(e) = fs::create_dir_all(&path) { + error!("crear_dirs_g_radio: no se pudo crear {}: {}", path.display(), e); + } + } + info!("crear_dirs_g_radio: estructura ~/G Radio/ verificada"); + + // Si gradio.config no existe aún, escribir uno con las rutas por defecto + let cfg_path = config_path(); + if cfg_path.exists() { + return; + } + if let Some(parent) = cfg_path.parent() { + let _ = fs::create_dir_all(parent); + } + let pisadores = base.join("pisadores"); + let intercult = base.join("Intercultural"); + // Formato: una opción por línea (ver read_gradio_config) + // L1: main_dev L2: cue_dev L3: station_name L4: crossfade + // L5: pisador_enabled L6: pisador_dir L7: pisador_every L8: pisador_exclude + // L9: silence_secs L10: nacionales L11: intercultural + let config_content = format!( + "\n\nG Radio\n3\n1\n{pisadores}\n5\n\n5.0\n\n\"{intercultural}\"\n", + pisadores = pisadores.display(), + intercultural = intercult.display(), + ); + match fs::write(&cfg_path, &config_content) { + Ok(_) => info!("crear_dirs_g_radio: gradio.config inicial escrito en {}", cfg_path.display()), + Err(e) => error!("crear_dirs_g_radio: no se pudo escribir gradio.config: {}", e), + } +} + +// ─── Instalación inicial de archivos de hora ───────────────────────────────── + +/// Copia los archivos de hora (HRS*.mp3, MIN*.mp3) desde la carpeta `data/Time/` +/// junto al ejecutable hacia `~/.gradio/data/panel/Time/`. +/// Solo actúa si el destino está vacío o no existe. +fn install_time_files_if_needed(home: &std::path::Path) { + let dest = home.join(".gradio/data/panel/Time"); + if dest.join("HRS00.mp3").exists() { + return; + } + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_default(); + let src = exe_dir.join("data").join("Time"); + if !src.is_dir() { + warn!("install_time_files: carpeta fuente no encontrada en {}", src.display()); + return; + } + if let Err(e) = fs::create_dir_all(&dest) { + error!("install_time_files: no se pudo crear {}: {}", dest.display(), e); + return; + } + let entries = match fs::read_dir(&src) { + Ok(e) => e, + Err(e) => { error!("install_time_files: error leyendo {}: {}", src.display(), e); return; } + }; + let mut count = 0u32; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("mp3") { + let dst = dest.join(entry.file_name()); + if !dst.exists() { + if let Err(e) = fs::copy(&path, &dst) { + error!("install_time_files: error copiando {}: {}", path.display(), e); + } else { + count += 1; + } + } + } + } + if count > 0 { + info!("install_time_files: {} archivos instalados en {}", count, dest.display()); + } +} + +// ─── Función principal ──────────────────────────────────────────────────────── + +fn main() { + env_logger::init(); + + gst::init().expect("No se pudo inicializar GStreamer"); + + // Iniciar servidor TCP para gr-client remoto + let gr_cfg_srv = read_gradio_config(); + + // i18n: forzado por config o autodetectado del entorno + let lc = i18n::init(gr_cfg_srv.locale.as_deref()); + eprintln!("[radio-player] locale = {}", lc.code()); + + let cliente_conectado: servidor::ClienteConectado = Arc::new(Mutex::new(String::new())); + servidor::iniciar(gr_cfg_srv.client_port, gr_cfg_srv.client_token.clone(), cliente_conectado.clone()); + + // Iniciar relay internet si está habilitado + if gr_cfg_srv.relay_habilitado && gr_cfg_srv.relay_id.len() == 8 + && gr_cfg_srv.relay_id.chars().all(|c| c.is_ascii_digit()) + { + relay::iniciar(gr_cfg_srv.relay_id, gr_cfg_srv.client_port, cliente_conectado.clone()); + } + + let app = Application::builder() + .application_id("com.gradio.radio-player") + .build(); + + app.connect_activate(move |app| { + build_ui(app, cliente_conectado.clone()); + }); + + app.run(); +} + +fn build_ui(app: &Application, cliente_conectado: servidor::ClienteConectado) { + let home = home_dir(); + + crear_dirs_g_radio(&home); + install_time_files_if_needed(&home); + skin::crear_dir_skins_si_falta(); + + // Instalar ícono en hicolor para que el menú y taskbar lo muestren correctamente. + // Se instala en 48x48 (requerido por Cinnamon/GNOME para el menú) y 256x256. + { + let hicolor = home.join(".local/share/icons/hicolor"); + let sizes = ["48x48", "256x256"]; + let mut installed = false; + for size in sizes { + let icon_path = hicolor.join(size).join("apps").join("gradio-player.png"); + if !icon_path.exists() { + if let Some(parent) = icon_path.parent() { + let _ = fs::create_dir_all(parent); + } + if fs::write(&icon_path, icon_gradio()).is_ok() { + installed = true; + } + } + } + if installed { + let _ = std::process::Command::new("gtk-update-icon-cache") + .args(["-f", "-t", &hicolor.to_string_lossy().into_owned()]) + .output(); + } + } + + // ── Cargar configuración inicial ── + let upvol_path = home.join(".gradio/data/tmp/upvol"); + let downvol_path = home.join(".gradio/data/tmp/downvol"); + let playlist_path = home.join(".gradio/data/tmp/playlist4"); + + // Crear archivos de volumen con valores por defecto si no existen + if !upvol_path.exists() { + let _ = fs::write(&upvol_path, "90"); + } + if !downvol_path.exists() { + let _ = fs::write(&downvol_path, "20"); + } + + let upvol_raw = read_f64_from_file(&upvol_path).clamp(0.0, 100.0) / 100.0; + let downvol_raw = read_f64_from_file(&downvol_path).clamp(0.0, 100.0) / 100.0; + let gr_cfg = read_gradio_config(); + let crossfade_secs = gr_cfg.crossfade_secs; + + let playlist = load_playlist(&playlist_path); + info!("Playlist cargada: {} temas", playlist.len()); + + // ── Estado compartido ── + let state = Arc::new(Mutex::new({ + let mut s = AppState::new(); + s.playlist = playlist; + s.upvol = upvol_raw; + s.downvol = downvol_raw; + s.crossfade_secs = crossfade_secs; + s.station_name = gr_cfg.station_name.clone(); + { + // Crear el Arc único en arranque y registrarlo en el global. + // Todos los pipelines comparten este mismo Arc para siempre. + let proc_cfg = processor::ProcessorConfig::cargar(); + let proc_dsp = processor::nuevo_shared_dsp(proc_cfg.clone()); + *proc_dsp_global().lock().unwrap() = Some(proc_dsp.clone()); + PROC_ENABLED.store(proc_cfg.enabled, std::sync::atomic::Ordering::Relaxed); + if proc_cfg.enabled { + s.dsp = Some(proc_dsp); // UI: "encendido" + } + // Si disabled: PROC_DSP_GLOBAL tiene el Arc pero AppState.dsp = None (UI "apagado") + } + s + })); + + // ── Timer: escribir estado.json para gr-client cada 2 segundos ── + { + let state_json = state.clone(); + let home_json = home.clone(); + timeout_add_local(std::time::Duration::from_secs(2), move || { + let st = state_json.lock().unwrap(); + let track_actual = st.current_track_path.to_string_lossy().to_string(); + let titulo_actual = Path::new(&track_actual) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let upvol = (st.upvol * 100.0).round() as u8; + let downvol = (st.downvol * 100.0).round() as u8; + let reproduciendo = matches!(st.deck_a_state, PlaybackState::Playing) + || matches!(st.deck_b_state, PlaybackState::Playing); + let pausado = matches!(st.deck_a_state, PlaybackState::Paused) + || matches!(st.deck_b_state, PlaybackState::Paused); + let comerciales_activos = st.commercials_playing; + let eventos_activos = st.eventos_playing; + let indice_actual = st.current_index; + drop(st); + + let tmp = home_json.join(".gradio/data/tmp"); + let num_comerciales = contar_lineas_archivo(&tmp.join("comercialeslist4")) as i32; + let num_eventos = contar_lineas_archivo(&tmp.join("eventoslist")) as i32; + let num_eventos_espera = contar_lineas_archivo(&tmp.join("eventos-esperalist")) as i32; + + let estado = servidor::EstadoRemoto { + reproduciendo, + pausado, + track_actual, + titulo_actual, + posicion_secs: 0.0, + duracion_secs: 0.0, + upvol, + downvol, + comerciales_activos, + eventos_activos, + num_comerciales, + num_eventos, + num_eventos_espera, + indice_actual, + }; + servidor::escribir_estado_json(&estado); + glib::ControlFlow::Continue + }); + } + + // ── Ventana ── + let window = ApplicationWindow::builder() + .application(app) + .title(i18n::tr("win.player")) + .default_width(900) + .default_height(520) + .build(); + + // Aplicar CSS + let provider = CssProvider::new(); + provider.load_from_data(APP_CSS); + gtk4::style_context_add_provider_for_display( + >k4::prelude::WidgetExt::display(&window), + &provider, + STYLE_PROVIDER_PRIORITY_APPLICATION + 200, + ); + skin::aplicar_css_extra(>k4::prelude::WidgetExt::display(&window)); + + // ── Layout principal ── + let root = GtkBox::new(Orientation::Vertical, 8); + root.set_margin_top(10); + root.set_margin_bottom(10); + root.set_margin_start(10); + root.set_margin_end(10); + + // ── Cabecera: reloj + estado ── + let header = GtkBox::new(Orientation::Horizontal, 12); + header.add_css_class("header-bar"); + header.set_margin_top(0); + header.set_margin_bottom(0); + let clock_label = Label::new(Some("00:00:00")); + clock_label.add_css_class("clock-label"); + clock_label.set_margin_start(8); + // Barra de progreso global (nombre del tema en reproducción) + let now_playing = Label::new(Some("G Radio Player")); + now_playing.add_css_class("now-playing-label"); + now_playing.set_hexpand(true); + now_playing.set_halign(gtk4::Align::Center); + now_playing.set_ellipsize(gtk4::pango::EllipsizeMode::End); + let lbl_station = Label::new(Some(gr_cfg.station_name.as_str())); + lbl_station.add_css_class("station-name-lbl"); + lbl_station.set_halign(gtk4::Align::End); + let status_label = Label::new(Some("")); + status_label.add_css_class("status-label"); + status_label.set_margin_end(8); + + // Logo G Radio a la derecha del nombre de la radio + let logo_gradio = { + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + loader.write(&icon_gradio()).unwrap_or(()); + loader.close().unwrap_or(()); + if let Some(pb) = loader.pixbuf() { + if let Some(sc) = pb.scale_simple(48, 48, gdk4::gdk_pixbuf::InterpType::Bilinear) { + let tex = gdk4::Texture::for_pixbuf(&sc); + let img = gtk4::Image::from_paintable(Some(&tex)); + img.set_size_request(48, 48); // forzar tamaño de renderizado + img.set_margin_end(8); + img + } else { + let img = gtk4::Image::new(); + img.set_size_request(48, 48); + img + } + } else { + let img = gtk4::Image::new(); + img.set_size_request(48, 48); + img + } + }; + + // Shared state para el VU + let vu_level_l_draw = std::rc::Rc::new(std::cell::Cell::new(0.0f64)); + let vu_level_r_draw = std::rc::Rc::new(std::cell::Cell::new(0.0f64)); + let vu_active_draw = std::rc::Rc::new(std::cell::Cell::new(true)); + // Logo + versión (caja vertical a la derecha del header) + let logo_box = GtkBox::new(Orientation::Vertical, 1); + logo_box.set_halign(gtk4::Align::Center); + logo_box.set_valign(gtk4::Align::Center); + let lbl_version = Label::new(Some(concat!("G Radio v-", env!("CARGO_PKG_VERSION")))); + lbl_version.add_css_class("version-lbl"); + logo_box.append(&logo_gradio); + logo_box.append(&lbl_version); + + header.append(&clock_label); + header.append(&now_playing); + header.append(&lbl_station); + header.append(&status_label); + header.append(&logo_box); + root.append(&header); + + // ── VU Meter — LEDs Cairo bajo el header ───────────────────────────────── + const VU_LEDS: i32 = 24; + const VU_LED_W: f64 = 15.0; + const VU_LED_H: f64 = 8.0; + const VU_LED_GAP: f64 = 2.0; + + // Función que dibuja una barra de LEDs dado un nivel 0.0-1.0 + fn draw_vu_leds(cr: &cairo::Context, _width: i32, height: i32, level: f64) { + let n = VU_LEDS; + let start_x = 0.0_f64; + let y = ((height as f64) - VU_LED_H) / 2.0; + let lit = (level * n as f64).round() as i32; + for i in 0..n { + let x = start_x + i as f64 * (VU_LED_W + VU_LED_GAP); + let on = i < lit; + // Verde 0-55%, amarillo 55-75%, rojo 75-100% + let (r, g, b) = if i >= (n as f64 * 0.75) as i32 { + if on { (1.0, 0.07, 0.0) } else { (0.25, 0.02, 0.0) } + } else if i >= (n as f64 * 0.55) as i32 { + if on { (0.9, 0.78, 0.0) } else { (0.22, 0.19, 0.0) } + } else { + if on { (0.0, 0.87, 0.13) } else { (0.0, 0.17, 0.03) } + }; + cr.set_source_rgb(r, g, b); + // LED con esquinas redondeadas (radio 1.5) + let rad = 1.5_f64; + cr.new_sub_path(); + cr.arc(x + VU_LED_W - rad, y + rad, rad, -std::f64::consts::PI/2.0, 0.0); + cr.arc(x + VU_LED_W - rad, y + VU_LED_H - rad, rad, 0.0, std::f64::consts::PI/2.0); + cr.arc(x + rad, y + VU_LED_H - rad, rad, std::f64::consts::PI/2.0, std::f64::consts::PI); + cr.arc(x + rad, y + rad, rad, std::f64::consts::PI, 3.0*std::f64::consts::PI/2.0); + cr.close_path(); + let _ = cr.fill(); + } + } + + let vu_bar_l = DrawingArea::new(); + let vu_bar_r = DrawingArea::new(); + vu_bar_l.set_hexpand(true); + vu_bar_r.set_hexpand(true); + vu_bar_l.set_content_height(14); + vu_bar_r.set_content_height(14); + + { + let lev = vu_level_l_draw.clone(); + vu_bar_l.set_draw_func(move |_da, cr, w, h| { + draw_vu_leds(cr, w, h, lev.get()); + }); + } + { + let lev = vu_level_r_draw.clone(); + vu_bar_r.set_draw_func(move |_da, cr, w, h| { + draw_vu_leds(cr, w, h, lev.get()); + }); + } + + // Etiquetas "L" / "R" + let lbl_l = Label::new(Some("L")); + let lbl_r = Label::new(Some("R")); + lbl_l.add_css_class("vu-ch-label"); + lbl_r.add_css_class("vu-ch-label"); + + let row_l = GtkBox::new(Orientation::Horizontal, 2); + row_l.append(&lbl_l); + row_l.append(&vu_bar_l); + + let row_r = GtkBox::new(Orientation::Horizontal, 2); + row_r.append(&lbl_r); + row_r.append(&vu_bar_r); + + // Branding: "G-Radio player" (rojo) + "Pulso" (violeta, más pequeño) + let gradio_label = Label::new(Some("G-Radio player")); + gradio_label.add_css_class("vu-gradio-label"); + gradio_label.set_halign(gtk4::Align::End); + + let pulso_label = Label::new(Some("Pulso")); + pulso_label.add_css_class("vu-pulso-label"); + pulso_label.set_halign(gtk4::Align::End); + + let brand_box = GtkBox::new(Orientation::Vertical, 0); + brand_box.set_valign(gtk4::Align::Center); + brand_box.set_halign(gtk4::Align::End); + brand_box.append(&gradio_label); + brand_box.append(&pulso_label); + + let vu_bars = GtkBox::new(Orientation::Vertical, 0); + vu_bars.set_hexpand(true); + vu_bars.append(&row_l); + vu_bars.append(&row_r); + + let lbl_cliente = Label::new(None); + lbl_cliente.add_css_class("vu-cliente-label"); + lbl_cliente.set_halign(gtk4::Align::Center); + lbl_cliente.set_hexpand(true); + + let vu_row = GtkBox::new(Orientation::Horizontal, 8); + vu_row.add_css_class("vu-row"); + vu_row.append(&vu_bars); + vu_row.append(&lbl_cliente); + vu_row.append(&brand_box); + root.append(&vu_row); + + // ── Players: cuántos paneles de reproducción se muestran (3/2/1) ── + // Requiere reinicio para aplicarse (se lee una sola vez acá). En modo 3 + // el comportamiento es exactamente el de siempre: ambos decks fijos y + // visibles. En modo 2/1, ambos decks se siguen construyendo (más simple + // y sin tocar el resto del tick) pero solo el que sea `active_deck` en + // cada momento queda visible; sus botones resuelven el deck en el + // instante del click (`None` = dinámico) para que "sigan" al deck que + // tenga el control aunque el tick de visibilidad tarde hasta 200ms en + // reflejarlo. + let players_mode = gr_cfg.players; + let deck_a_param = if players_mode == 3 { Some(ActiveDeck::A) } else { None }; + let deck_b_param = if players_mode == 3 { Some(ActiveDeck::B) } else { None }; + + // ── Deck A ── + let title_a = Label::new(Some(i18n::tr("now.no_track"))); + let time_a = Label::new(Some("00:00")); + let total_a = Label::new(Some("00:00")); + let adj_a = Adjustment::new(0.0, 0.0, 100.0, 1.0, 10.0, 0.0); + let seek_a = Scale::new(Orientation::Horizontal, Some(&adj_a)); + seek_a.set_draw_value(false); + + let (deck_a_frame, btn_stop_end_a) = build_deck_frame( + i18n::tr("deck.a"), + state.clone(), + deck_a_param, + &title_a, + &time_a, + &total_a, + &seek_a, + ); + + // ── Deck B ── + let title_b = Label::new(Some(i18n::tr("now.no_track"))); + let time_b = Label::new(Some("00:00")); + let total_b = Label::new(Some("00:00")); + let adj_b = Adjustment::new(0.0, 0.0, 100.0, 1.0, 10.0, 0.0); + let seek_b = Scale::new(Orientation::Horizontal, Some(&adj_b)); + seek_b.set_draw_value(false); + + let (deck_b_frame, btn_stop_end_b) = build_deck_frame( + i18n::tr("deck.b"), + state.clone(), + deck_b_param, + &title_b, + &time_b, + &total_b, + &seek_b, + ); + + let decks_row = GtkBox::new(Orientation::Horizontal, 8); + deck_a_frame.set_hexpand(true); + deck_b_frame.set_hexpand(true); + decks_row.append(&deck_a_frame); + decks_row.append(&deck_b_frame); + root.append(&decks_row); + if players_mode != 3 { + // Estado inicial: al arrancar, active_deck es A y no hay comercial. + deck_a_frame.set_visible(true); + deck_b_frame.set_visible(false); + } + + // ── Barra de comerciales en curso ── + let comm_bar = GtkBox::new(Orientation::Horizontal, 8); + comm_bar.set_margin_top(2); + comm_bar.set_margin_bottom(2); + let comm_icon = Label::new(Some("📢")); + let comm_title = Label::new(Some(i18n::tr("now.no_ad"))); + comm_title.add_css_class("track-title"); + comm_title.set_hexpand(true); + comm_title.set_ellipsize(gtk4::pango::EllipsizeMode::End); + let comm_time = Label::new(Some("00:00")); + comm_time.add_css_class("time-label"); + let comm_total = Label::new(Some("00:00")); + comm_total.add_css_class("time-label"); + let comm_adj = Adjustment::new(0.0, 0.0, 100.0, 1.0, 10.0, 0.0); + let comm_seek = Scale::new(Orientation::Horizontal, Some(&comm_adj)); + comm_seek.set_draw_value(false); + comm_seek.set_hexpand(true); + comm_bar.append(&comm_icon); + comm_bar.append(&comm_title); + comm_bar.append(&comm_time); + comm_bar.append(&comm_seek); + comm_bar.append(&comm_total); + root.append(&comm_bar); + if players_mode == 1 { + // Modo 1: un solo panel visible — arranca mostrando el deck activo. + comm_bar.set_visible(false); + } + + // ── Barra de controles + botones externos + vol info ── + let global_box = GtkBox::new(Orientation::Horizontal, 6); + global_box.set_margin_top(4); + global_box.set_margin_bottom(2); + + // Botones principales de playlist + let btn_start = icon_button(&icon_gr_off(), i18n::tr("tool.start"), 36); + btn_start.add_css_class("global-icon-btn"); + let btn_stop_general = icon_button(&icon_stop(), i18n::tr("tool.stop_general"), 36); + btn_stop_general.add_css_class("global-icon-btn"); + btn_stop_general.add_css_class("stop-general-btn"); + let btn_next = icon_button(&icon_siguiente(), i18n::tr("tool.next"), 36); + btn_next.add_css_class("global-icon-btn"); + + // Botón Hora + let btn_hora = icon_button(&icon_hora_off(), i18n::tr("tool.hora"), 36); + btn_hora.add_css_class("global-icon-btn"); + + // Botón Pisador + let btn_pisador = icon_button(&icon_pisador(), i18n::tr("tool.pisador"), 36); + btn_pisador.add_css_class("global-icon-btn"); + + // Duck toggle (movido aquí desde arriba) + let duck_btn = icon_toggle_button(&icon_fadeout_off(), i18n::tr("tool.duck"), 36); + duck_btn.add_css_class("global-icon-btn"); + let btn_buscador = icon_button(&icon_busqueda(), i18n::tr("tool.buscador"), 36); + btn_buscador.add_css_class("global-icon-btn"); + + // Botones de programas externos + let btn_pautaje = icon_button(&icon_pautaje(), i18n::tr("tool.pautaje"), 36); + btn_pautaje.add_css_class("global-icon-btn"); + let btn_parrilla = icon_button(&icon_parrilla(), i18n::tr("tool.parrilla"), 36); + btn_parrilla.add_css_class("global-icon-btn"); + let btn_playlist = icon_button(&icon_playlist48(), i18n::tr("tool.playlist"), 36); + btn_playlist.add_css_class("global-icon-btn"); + let btn_botonera = icon_button(&icon_botonera(), i18n::tr("tool.botonera"), 36); + btn_botonera.add_css_class("global-icon-btn"); + let btn_config = icon_button(&icon_config(), i18n::tr("tool.config"), 36); + btn_config.add_css_class("global-icon-btn"); + let btn_visor = icon_button(&icon_visor(), i18n::tr("tool.visor"), 36); + btn_visor.add_css_class("global-icon-btn"); + let btn_record = icon_button(&icon_grabar(), i18n::tr("tool.record"), 36); + btn_record.add_css_class("global-icon-btn"); + let btn_operador = Button::with_label(i18n::tr("btn.operador")); + btn_operador.add_css_class("ext-btn"); + let btn_reportes = icon_button(&icon_reportes(), i18n::tr("tool.reportes"), 36); + btn_reportes.add_css_class("global-icon-btn"); + + // VU Meter — cargar SVG como PNG vía pixbuf + let btn_vumeter = { + let btn = Button::new(); + btn.set_tooltip_text(Some(i18n::tr("tip.vu_meter"))); + btn.set_size_request(36, 36); + btn.add_css_class("global-icon-btn"); + // Intentar cargar el SVG; si falla usar texto + let svg_bytes = include_bytes!("../assets/VUmetro.svg"); + let loader = gdk4::gdk_pixbuf::PixbufLoader::with_type("svg").unwrap_or_else(|_| + gdk4::gdk_pixbuf::PixbufLoader::new() + ); + let _ = loader.set_size(26, 26); + loader.write(svg_bytes).unwrap_or(()); + loader.close().unwrap_or(()); + if let Some(pb) = loader.pixbuf() { + let tex = gdk4::Texture::for_pixbuf(&pb); + let img = gtk4::Image::from_paintable(Some(&tex)); + btn.set_child(Some(&img)); + } else { + btn.set_label("VU"); + } + btn + }; + + // Etiqueta de volumen (al final de la barra) + let vol_info = Label::new(Some(&i18n::tr("vol.fmt") + .replace("{up}", &format!("{:.0}", upvol_raw * 100.0)) + .replace("{dn}", &format!("{:.0}", downvol_raw * 100.0)) + .replace("{mix}", &format!("{:.1}", crossfade_secs)))); + vol_info.add_css_class("status-label"); + vol_info.set_hexpand(false); + vol_info.set_halign(gtk4::Align::Start); + + // Señal Stop General + { + let s = state.clone(); + btn_stop_general.connect_clicked(move |_| { + interrupt_pautaje(&s); + let mut st = s.lock().unwrap(); + action_stop(&mut st, ActiveDeck::A); + action_stop(&mut st, ActiveDeck::B); + info!("Stop General activado desde UI"); + }); + } + + // Grupo izquierdo: Iniciar, Stop General, Siguiente, Hora, Pisador, Bajar música, Buscador, Playlist + let left_group = GtkBox::new(Orientation::Horizontal, 4); + left_group.append(&btn_start); + left_group.append(&btn_stop_general); + left_group.append(&btn_next); + left_group.append(&btn_hora); + left_group.append(&btn_pisador); + left_group.append(&duck_btn); + left_group.append(&btn_buscador); + left_group.append(&btn_playlist); + + // Separador expansible + let sep = Label::new(None); + sep.set_hexpand(true); + + // Botón toggle procesador — icono cambia según estado activo/inactivo + let proc_on_init = PROC_ENABLED.load(std::sync::atomic::Ordering::Relaxed); + let proc_icon_init = if proc_on_init { icon_proc_on() } else { icon_proc_off() }; + let btn_processor = icon_button( + &proc_icon_init, + if proc_on_init { i18n::tr("tip.dsp_on") } + else { i18n::tr("tip.dsp_off") }, + 40, + ); + if proc_on_init { btn_processor.add_css_class("processor-on"); } + { + let s = state.clone(); + btn_processor.connect_clicked(move |btn| { + let mut st = s.lock().unwrap(); + // PROC_ENABLED es el control autoritativo: toggle atómico sin depender de mutex. + let turning_on = !PROC_ENABLED.load(std::sync::atomic::Ordering::Relaxed); + PROC_ENABLED.store(turning_on, std::sync::atomic::Ordering::Relaxed); + // Persistir en disco (best-effort: si try_lock falla no es crítico, + // PROC_ENABLED ya controla el procesamiento en tiempo real). + if let Ok(g) = proc_dsp_global().try_lock() { + if let Some(ref dsp) = *g { + if let Ok(mut d) = dsp.try_lock() { + d.cfg.enabled = turning_on; + d.cfg.guardar(); + } + } + } + if turning_on { + // Actualizar st.dsp para consistencia con apply() — best-effort. + if let Ok(g) = proc_dsp_global().try_lock() { + st.dsp = (*g).clone(); + } + btn.add_css_class("processor-on"); + btn.set_tooltip_text(Some(i18n::tr("tip.dsp_on"))); + set_button_icon(btn, &icon_proc_on(), 40); + } else { + st.dsp = None; + btn.remove_css_class("processor-on"); + btn.set_tooltip_text(Some(i18n::tr("tip.dsp_off"))); + set_button_icon(btn, &icon_proc_off(), 40); + } + }); + } + + // Botón configuración del procesador DSP + let btn_dsp_cfg = icon_button(&icon_proc_cfg(), "Configurar procesador DSP", 40); + { + let win_c = window.clone(); + let state_c = state.clone(); + btn_dsp_cfg.connect_clicked(move |_| { + abrir_ventana_procesador(&win_c, state_c.clone()); + }); + } + + // Botón IA — visible solo cuando ia_habilitada está activo en la configuración + let btn_ia = { + let b = Button::new(); + let lbl = gtk4::Label::new(Some(i18n::tr("btn.ia"))); + b.set_child(Some(&lbl)); + b.set_size_request(36, 36); + b.set_hexpand(false); + b.set_vexpand(false); + b.add_css_class("global-icon-btn"); + b.set_tooltip_text(Some(i18n::tr("tip.ia_btn"))); + let ia_on = read_gradio_config().ia_habilitada; + b.set_visible(ia_on); + b + }; + { + let home_ia = home.clone(); + let win_ia = window.clone(); + btn_ia.connect_clicked(move |_| { + let opencode_dir = home_ia.join(".gradio/data/opencode"); + // Verificar que existe opencode (primero en rutas conocidas, luego PATH) + let opencode_bin = { + let local = home_ia.join(".local/bin/opencode"); + let ocode = home_ia.join(".opencode/bin/opencode"); + if local.exists() { Some(local) } + else if ocode.exists() { Some(ocode) } + else if std::process::Command::new("which").arg("opencode") + .output().map(|o| o.status.success()).unwrap_or(false) { + Some(std::path::PathBuf::from("opencode")) + } else { None } + }; + if opencode_bin.is_none() { + show_info_dialog(win_ia.upcast_ref::(), i18n::tr("dlg.ia_warn_title"), i18n::tr("dlg.ia_no_bin")); + return; + } + let bin = opencode_bin.unwrap(); + // Asegurar que existe el directorio de contexto + let _ = fs::create_dir_all(&opencode_dir); + // Intentar lanzar opencode-terminal; si no está o falla (ej. glibc), + // usar fallback con terminal del sistema pasando el project dir como argumento. + match std::process::Command::new("opencode-terminal") + .env("GRADIO_OPENCODE_DIR", &opencode_dir) + .spawn() + { + Ok(mut child) => { + // El proceso arrancó, pero puede morir inmediatamente por incompatibilidad + // (ej. glibc mismatch en Bookworm). Verificar tras 500ms. + let bin_t = bin.clone(); + let dir_t = opencode_dir.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + if let Ok(Some(status)) = child.try_wait() { + if !status.success() { + launch_opencode_en_terminal(&bin_t, &dir_t); + } + } + }); + } + Err(_) => { + // opencode-terminal no encontrado en PATH + launch_opencode_en_terminal(&bin, &opencode_dir); + } + } + }); + } + + // Grupo derecho: IA, Vol info, Botonera, Pautaje, Parrilla, Config, Visor, Grabar, VU Meter, Reportes, Procesador, Cfg DSP + let right_group = GtkBox::new(Orientation::Horizontal, 4); + right_group.set_halign(gtk4::Align::End); + right_group.append(&vol_info); + right_group.append(&btn_botonera); + right_group.append(&btn_pautaje); + right_group.append(&btn_parrilla); + right_group.append(&btn_config); + right_group.append(&btn_visor); + right_group.append(&btn_record); + right_group.append(&btn_vumeter); + right_group.append(&btn_reportes); + right_group.append(&btn_processor); + right_group.append(&btn_dsp_cfg); + right_group.append(&btn_ia); + + global_box.append(&left_group); + global_box.append(&sep); + global_box.append(&right_group); + root.append(&global_box); + + // ── Paneles de lista: Playlist (izq) | Comerciales (der) ── + let lists_row = GtkBox::new(Orientation::Horizontal, 8); + lists_row.set_vexpand(true); + + // Panel izquierdo: Playlist musical interactiva + let playlist_frame = Frame::new(None::<&str>); + playlist_frame.add_css_class("list-frame"); + playlist_frame.set_hexpand(true); + playlist_frame.set_vexpand(true); + let playlist_listbox = ListBox::new(); + playlist_listbox.add_css_class("queue-list"); + playlist_listbox.set_selection_mode(gtk4::SelectionMode::Single); + // Doble clic activa la fila (no clic simple), para no interferir con tooltips + playlist_listbox.set_activate_on_single_click(false); + + // El ScrolledWindow se crea aquí (antes que los DropTargets) para poder + // instalarles también drop sobre el área visible más allá de la última fila. + let playlist_scroll = ScrolledWindow::new(); + playlist_scroll.set_vexpand(true); + playlist_scroll.set_min_content_height(80); + playlist_scroll.set_child(Some(&playlist_listbox)); + + // ── DropTarget unificado: reordenamiento interno + archivos externos ── + // Acepta STRING (arrastre interno playlist-row:N) y + // gdk4::FileList (text/uri-list de gestores de archivos como Nautilus/Thunar). + // Se instalan también sobre el ScrolledWindow para cubrir el área vacía + // bajo la última fila (cuando la ventana es alta o hay pocas filas). + { + let s = state.clone(); + let home_pl = home.clone(); + + // (insert_paths_validated vive a nivel de módulo — valida con symphonia) + + // ── DropTarget único: STRING para reordenamiento interno + URIs de file managers ── + let drop_target = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY); + { + let s2 = s.clone(); + let home_pl2 = home.clone(); + drop_target.connect_drop(move |_target, value, _x, y_pos| { + let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4"); + let insert_at = (y_pos / 30.0) as usize; + + let text = match value.get::() { + Ok(t) => t, + Err(_) => return false, + }; + let text = text.trim().to_string(); + + if text.starts_with("playlist-row:") { + // ── Reordenamiento interno ── + let from_idx: usize = match text["playlist-row:".len()..].parse() { + Ok(n) => n, + Err(_) => return false, + }; + let mut st = s2.lock().unwrap(); + if st.playlist.is_empty() { return false; } + let to_idx = insert_at.min(st.playlist.len().saturating_sub(1)); + if from_idx == to_idx || from_idx >= st.playlist.len() { + return false; + } + let track = st.playlist.remove(from_idx); + let real_to = if from_idx < to_idx { to_idx - 1 } else { to_idx }; + st.playlist.insert(real_to, track); + save_playlist_to_file(&st.playlist, &playlist_path); + st.playlist_version = st.playlist_version.wrapping_add(1); + info!("Reordenado: {} → {}", from_idx, real_to); + true + } else { + // ── URI como texto (text/uri-list como STRING fallback) ── + let paths: Vec = text.lines() + .map(|l| l.trim().trim_start_matches("file://").to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + let mut st = s2.lock().unwrap(); + let at = insert_at.min(st.playlist.len()); + let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path); + inserted > 0 + } + }); + } + playlist_listbox.add_controller(drop_target); + + // DropTarget para FileList (múltiples archivos desde Nemo/Thunar) + let drop_files = DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY); + { + let s2 = s.clone(); + let home_pl2 = home.clone(); + drop_files.connect_drop(move |_target, value, _x, y_pos| { + if let Ok(file_list) = value.get::() { + let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4"); + let insert_at = (y_pos / 30.0) as usize; + + let paths: Vec = file_list.files() + .into_iter() + .filter_map(|f| f.path()) + .collect(); + + if paths.is_empty() { return false; } + + let mut st = s2.lock().unwrap(); + let at = insert_at.min(st.playlist.len()); + let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path); + info!("Drop externo FileList: {} archivos insertados en {}", inserted, at); + return inserted > 0; + } + false + }); + } + playlist_listbox.add_controller(drop_files); + + // ── DropTargets sobre el ScrolledWindow ─────────────────────────────── + // Cubren el área visible más allá de la última fila del ListBox + // (un ListBox solo asigna alto al contenido; debajo queda viewport vacío). + // Si el cursor está sobre una fila, los DropTargets del listbox capturan + // primero; si está en el área vacía, llegan estos y se inserta al final. + let drop_target_scroll = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY); + { + let s2 = s.clone(); + let home_pl2 = home.clone(); + drop_target_scroll.connect_drop(move |_target, value, _x, _y_pos| { + let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4"); + + let text = match value.get::() { + Ok(t) => t, + Err(_) => return false, + }; + let text = text.trim().to_string(); + + // Reordenamiento interno: ignorar si llegó al área vacía + if text.starts_with("playlist-row:") { return false; } + + let paths: Vec = text.lines() + .map(|l| l.trim().trim_start_matches("file://").to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + let mut st = s2.lock().unwrap(); + let at = st.playlist.len(); + let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path); + inserted > 0 + }); + } + playlist_scroll.add_controller(drop_target_scroll); + + let drop_files_scroll = DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY); + { + let s2 = s.clone(); + let home_pl2 = home.clone(); + drop_files_scroll.connect_drop(move |_target, value, _x, _y_pos| { + if let Ok(file_list) = value.get::() { + let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4"); + let paths: Vec = file_list.files() + .into_iter() + .filter_map(|f| f.path()) + .collect(); + if paths.is_empty() { return false; } + let mut st = s2.lock().unwrap(); + let at = st.playlist.len(); + let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path); + info!("Drop scroll FileList: {} archivos al final", inserted); + return inserted > 0; + } + false + }); + } + playlist_scroll.add_controller(drop_files_scroll); + + // ── DropTargets sobre el Frame ──────────────────────────────────────── + // Cobertura final: el Frame es el contenedor más externo del panel y + // siempre cubre toda el área visible (incluido el header de la lista). + // Si por alguna razón el ScrolledWindow no recibe el evento (p.ej. al + // ampliar la ventana después de iniciar el drag), el Frame sí. + let drop_target_frame = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY); + { + let s2 = s.clone(); + let home_pl2 = home.clone(); + drop_target_frame.connect_drop(move |_target, value, _x, _y_pos| { + let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4"); + let text = match value.get::() { + Ok(t) => t, + Err(_) => return false, + }; + let text = text.trim().to_string(); + if text.starts_with("playlist-row:") { return false; } + let paths: Vec = text.lines() + .map(|l| l.trim().trim_start_matches("file://").to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + let mut st = s2.lock().unwrap(); + let at = st.playlist.len(); + let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path); + inserted > 0 + }); + } + playlist_frame.add_controller(drop_target_frame); + + let drop_files_frame = DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY); + { + let s2 = s.clone(); + let home_pl2 = home.clone(); + drop_files_frame.connect_drop(move |_target, value, _x, _y_pos| { + if let Ok(file_list) = value.get::() { + let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4"); + let paths: Vec = file_list.files() + .into_iter() + .filter_map(|f| f.path()) + .collect(); + if paths.is_empty() { return false; } + let mut st = s2.lock().unwrap(); + let at = st.playlist.len(); + let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path); + info!("Drop frame FileList: {} archivos al final", inserted); + return inserted > 0; + } + false + }); + } + playlist_frame.add_controller(drop_files_frame); + } + + // ── Doble clic sobre una fila: reproducir con crossfade ────────────────── + { + let s = state.clone(); + playlist_listbox.connect_row_activated(move |_lb, row| { + let idx = row.index() as usize; + let target = { + let st = s.lock().unwrap(); + match st.active_deck { + ActiveDeck::A => ActiveDeck::B, + ActiveDeck::B => ActiveDeck::A, + } + }; + play_track_to_deck(s.clone(), idx, target); + }); + } + + // Botón Automático — pausa/reanuda playlist-refill (robot.png) + // Verde oscuro = automático activo (estado natural) + // Rojo oscuro = llenado automático pausado (operador controla) + let btn_auto_refill = icon_toggle_button(&icon_robot(), i18n::tr("tool.auto_refill"), 28); + btn_auto_refill.add_css_class("refill-auto-btn"); + { + let pause_file_init = home.join(".gradio/data/tmp/pause_refill"); + btn_auto_refill.set_active(pause_file_init.exists()); + } + { + let home_ar = home.clone(); + btn_auto_refill.connect_toggled(move |btn| { + let pause_file = home_ar.join(".gradio/data/tmp/pause_refill"); + if btn.is_active() { + let _ = std::fs::write(&pause_file, ""); + info!("playlist-refill: llenado automático PAUSADO"); + } else { + let _ = std::fs::remove_file(&pause_file); + info!("playlist-refill: llenado automático ACTIVO"); + } + }); + } + + // Botón Vaciar parrilla — borra la cola de reproducción + let btn_vaciar_playlist = icon_button(&icon_vaciar(), i18n::tr("tool.vaciar_playlist"), 28); + btn_vaciar_playlist.add_css_class("vaciar-playlist-btn"); + { + let home_vp = home.clone(); + let state_vp = state.clone(); + btn_vaciar_playlist.connect_clicked(move |_| { + let playlist_path = home_vp.join(".gradio/data/tmp/playlist4"); + let mut st = state_vp.lock().unwrap(); + st.playlist.clear(); + save_playlist_to_file(&st.playlist, &playlist_path); + st.playlist_version = st.playlist_version.wrapping_add(1); + info!("Cola de reproducción vaciada manualmente"); + }); + } + + // Cabecera del panel playlist: label + duración total + botones (vaciar + automático) + let playlist_header = GtkBox::new(Orientation::Horizontal, 4); + playlist_header.add_css_class("playlist-header"); + let lbl_playlist_title = gtk4::Label::new(Some(i18n::tr("playlist.titulo"))); + lbl_playlist_title.set_hexpand(true); + lbl_playlist_title.set_halign(gtk4::Align::Start); + let lbl_total_dur = gtk4::Label::new(Some(&format!("{}: 00:00", i18n::tr("playlist.total")))); + lbl_total_dur.add_css_class("playlist-total-dur"); + lbl_total_dur.set_halign(gtk4::Align::End); + playlist_header.append(&lbl_playlist_title); + playlist_header.append(&lbl_total_dur); + playlist_header.append(&btn_vaciar_playlist); + playlist_header.append(&btn_auto_refill); + + let playlist_vbox = GtkBox::new(Orientation::Vertical, 0); + playlist_vbox.set_vexpand(true); + playlist_vbox.set_valign(gtk4::Align::Fill); + playlist_vbox.append(&playlist_header); + playlist_vbox.append(&playlist_scroll); + playlist_frame.set_child(Some(&playlist_vbox)); + + // Panel 1: Próximas tandas comerciales + let comerciales_outer_frame = Frame::new(Some(i18n::tr("panel.comerciales_tandas"))); + comerciales_outer_frame.add_css_class("list-frame"); + comerciales_outer_frame.set_hexpand(true); + comerciales_outer_frame.set_vexpand(true); + + let comerciales_scroll = ScrolledWindow::new(); + comerciales_scroll.set_vexpand(true); + comerciales_scroll.set_min_content_height(120); + + // Box que contiene las 4 tarjetas de tanda + let tandas_box = GtkBox::new(Orientation::Vertical, 4); + tandas_box.set_margin_start(4); + tandas_box.set_margin_end(4); + tandas_box.set_margin_top(4); + tandas_box.set_margin_bottom(4); + tandas_box.set_vexpand(true); + + comerciales_scroll.set_child(Some(&tandas_box)); + comerciales_outer_frame.set_child(Some(&comerciales_scroll)); + + // Panel 2: Cola de eventos en espera (eventos-esperalist) + let espera_frame = Frame::new(Some(i18n::tr("panel.eventos_espera"))); + espera_frame.add_css_class("list-frame"); + espera_frame.set_hexpand(true); + espera_frame.set_vexpand(true); + let espera_listbox = ListBox::new(); + espera_listbox.add_css_class("queue-list"); + espera_listbox.set_selection_mode(gtk4::SelectionMode::None); + let espera_scroll = ScrolledWindow::new(); + espera_scroll.set_vexpand(true); + espera_scroll.set_min_content_height(40); + espera_scroll.set_child(Some(&espera_listbox)); + espera_frame.set_child(Some(&espera_scroll)); + + // Panel 3: Cola de eventos (eventoslist) + let eventos_frame = Frame::new(Some(i18n::tr("panel.eventos"))); + eventos_frame.add_css_class("list-frame"); + eventos_frame.set_hexpand(true); + eventos_frame.set_vexpand(true); + let eventos_listbox = ListBox::new(); + eventos_listbox.add_css_class("queue-list"); + eventos_listbox.set_selection_mode(gtk4::SelectionMode::None); + let eventos_scroll = ScrolledWindow::new(); + eventos_scroll.set_vexpand(true); + eventos_scroll.set_min_content_height(40); + eventos_scroll.set_child(Some(&eventos_listbox)); + eventos_frame.set_child(Some(&eventos_scroll)); + + // ── Layout derecho: Paned redimensionable ───────────────────────────────── + // paned_eventos: separa espera (arriba) de eventos (abajo) + let paned_eventos = gtk4::Paned::new(Orientation::Vertical); + paned_eventos.set_hexpand(true); + paned_eventos.set_vexpand(true); + paned_eventos.set_wide_handle(true); + paned_eventos.set_start_child(Some(&espera_frame)); + paned_eventos.set_end_child(Some(&eventos_frame)); + paned_eventos.set_position(120); + // Ocultar hasta que haya eventos/espera + paned_eventos.set_visible(false); + + // paned_right: comerciales (arriba) | bloque de eventos (abajo) + let paned_right = gtk4::Paned::new(Orientation::Vertical); + paned_right.set_hexpand(true); + paned_right.set_vexpand(true); + paned_right.set_wide_handle(true); + paned_right.set_start_child(Some(&comerciales_outer_frame)); + paned_right.set_end_child(Some(&paned_eventos)); + // paned_eventos.set_visible(false) hace que start_child ocupe todo el espacio + + // ── Menús contextuales (clic derecho) para vaciar colas ────────────────── + agregar_menu_vaciar(&espera_frame, "Eventos en espera", home.join(".gradio/data/tmp/eventos-esperalist"), state.clone()); + agregar_menu_vaciar(&eventos_frame, "Eventos", home.join(".gradio/data/tmp/eventoslist"), state.clone()); + + lists_row.append(&playlist_frame); + lists_row.append(&paned_right); + + // ── DropTarget catch-all sobre lists_row ───────────────────────────────── + // Si la ventana se extiende tras abrir el buscador y los DropTargets + // internos (listbox/scroll/frame) no reciben el evento por la geometría + // resultante, este target captura el drop y rutea por coordenada x: si + // x cae dentro del playlist_frame → playlist; si no → ignorar (los + // DropTargets de comerciales se encargan de su lado). + { + use gtk4::prelude::WidgetExt; + let drop_lr_str = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY); + { + let s2 = state.clone(); + let home_lr = home.clone(); + let frame_w = playlist_frame.clone(); + drop_lr_str.connect_drop(move |_target, value, x, _y| { + let pl_right = (frame_w.allocation().x() + frame_w.allocation().width()) as f64; + if x >= pl_right { return false; } + let text = match value.get::() { Ok(t) => t, Err(_) => return false }; + let text = text.trim().to_string(); + if text.starts_with("playlist-row:") { return false; } + let paths: Vec = text.lines() + .map(|l| l.trim().trim_start_matches("file://").to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + let playlist_path = home_lr.join(".gradio/data/tmp/playlist4"); + let mut st = s2.lock().unwrap(); + let at = st.playlist.len(); + insert_paths_validated(paths, at, &mut st, &playlist_path) > 0 + }); + } + lists_row.add_controller(drop_lr_str); + + let drop_lr_files = DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY); + { + let s2 = state.clone(); + let home_lr = home.clone(); + let frame_w = playlist_frame.clone(); + drop_lr_files.connect_drop(move |_target, value, x, _y| { + let pl_right = (frame_w.allocation().x() + frame_w.allocation().width()) as f64; + if x >= pl_right { return false; } + if let Ok(file_list) = value.get::() { + let paths: Vec = file_list.files() + .into_iter() + .filter_map(|f| f.path()) + .collect(); + if paths.is_empty() { return false; } + let playlist_path = home_lr.join(".gradio/data/tmp/playlist4"); + let mut st = s2.lock().unwrap(); + let at = st.playlist.len(); + let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path); + info!("Drop lists_row FileList: {} archivos al final", inserted); + return inserted > 0; + } + false + }); + } + lists_row.add_controller(drop_lr_files); + } + + root.append(&lists_row); + + // ── Seek de comerciales ── + { + let s = state.clone(); + comm_seek.connect_change_value(move |_scale, _scroll, value| { + let st = s.lock().unwrap(); + if let Some(ref p) = st.pipeline_comm { + pipeline_seek(p, value); + } + glib::Propagation::Proceed + }); + } + + // ── Señal botón Hora ── + { + let s = state.clone(); + btn_hora.connect_clicked(move |_| { + let state_h = s.clone(); + glib::spawn_future_local(async move { + play_hora(&state_h).await; + }); + }); + } + + // ── Botón Pisador ── + { + let s = state.clone(); + let home_p = home.clone(); + btn_pisador.connect_clicked(move |_| { + let state_p = s.clone(); + let home_pp = home_p.clone(); + glib::spawn_future_local(async move { + play_pisador(&state_p, &home_pp).await; + }); + }); + } + + // ── Señales botones externos ── + { + btn_pautaje.connect_clicked(move |_| { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let prog = exe_dir.join(format!("gr-pautaje{}", EXE_EXT)); + if let Err(e) = std::process::Command::new(&prog).spawn() { + error!("No se pudo lanzar gr-pautaje ({:?}): {}", prog, e); + } + }); + } + { + btn_parrilla.connect_clicked(move |_| { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let prog = exe_dir.join(format!("gr-parrilla{}", EXE_EXT)); + if let Err(e) = std::process::Command::new(&prog).spawn() { + error!("No se pudo lanzar gr-parrilla ({:?}): {}", prog, e); + } + }); + } + { + btn_botonera.connect_clicked(move |_| { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let prog = exe_dir.join(format!("gr-botonera{}", EXE_EXT)); + if let Err(e) = std::process::Command::new(&prog).spawn() { + error!("No se pudo lanzar gr-botonera ({:?}): {}", prog, e); + } + }); + } + { + let win_cfg = window.upcast_ref::().clone(); + btn_config.connect_clicked(move |_| { + show_config_dialog(&win_cfg); + }); + } + { + btn_visor.connect_clicked(move |_| { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let prog = exe_dir.join(format!("gr-visor{}", EXE_EXT)); + if let Err(e) = std::process::Command::new(&prog).spawn() { + error!("No se pudo lanzar gr-visor ({:?}): {}", prog, e); + } + }); + } + { + let home_g = home.clone(); + btn_record.connect_clicked(move |_| { + let exe = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join(format!("gr-record{}", EXE_EXT)))) + .unwrap_or_else(|| std::path::PathBuf::from(format!("gr-record{}", EXE_EXT))); + if let Err(e) = std::process::Command::new(&exe).spawn() { + error!("No se pudo lanzar gr-record: {}", e); + } + }); + } + { + let home_g = home.clone(); + btn_operador.connect_clicked(move |_| { + let prog = home_g.join(".gradio/bin/GR-Operador.gambas"); + if let Err(e) = std::process::Command::new(&prog).spawn() { + error!("No se pudo lanzar Operador: {}", e); + } + }); + } + { + let s_vu = state.clone(); + btn_vumeter.connect_clicked(move |btn| { + let mut st = s_vu.lock().unwrap(); + st.vu_active = !st.vu_active; + // Indicar visualmente si está activo + if st.vu_active { + btn.remove_css_class("deck-icon-btn-active"); + btn.add_css_class("global-icon-btn"); + } else { + btn.remove_css_class("global-icon-btn"); + btn.add_css_class("deck-icon-btn-active"); + } + info!("VU meter: {}", if st.vu_active { "activado" } else { "desactivado" }); + }); + } + + // ── Señal Duck toggle ── + let s = state.clone(); + { + btn_buscador.connect_clicked(move |_| { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let prog = exe_dir.join(format!("gr-buscador{}", EXE_EXT)); + if let Err(e) = std::process::Command::new(&prog).spawn() { + error!("No se pudo lanzar gr-buscador ({:?}): {}", prog, e); + } + }); + } + { + btn_playlist.connect_clicked(move |_| { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let prog = exe_dir.join(format!("gr-playlist{}", EXE_EXT)); + if let Err(e) = std::process::Command::new(&prog).spawn() { + error!("No se pudo lanzar gr-playlist ({:?}): {}", prog, e); + } + }); + } + + { + btn_reportes.connect_clicked(move |_| { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let prog = exe_dir.join(format!("gr-reportes{}", EXE_EXT)); + if let Err(e) = std::process::Command::new(&prog).spawn() { + error!("No se pudo lanzar gr-reportes ({:?}): {}", prog, e); + } + }); + } + + duck_btn.connect_toggled(move |btn| { + let mut st = s.lock().unwrap(); + st.duck_active = btn.is_active(); + let target_vol = if btn.is_active() { st.downvol } else { st.upvol }; + for pipeline in [&st.pipeline_a, &st.pipeline_b].into_iter().flatten() { + set_pipeline_volume(pipeline, target_vol); + } + // Actualizar icono según estado + let icon = if btn.is_active() { icon_fadeout_on() } else { icon_fadeout_off() }; + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + loader.write(&icon).unwrap_or(()); + loader.close().unwrap_or(()); + if let Some(pb) = loader.pixbuf() { + if let Some(sc) = pb.scale_simple(26, 26, gdk4::gdk_pixbuf::InterpType::Bilinear) { + let tex = gdk4::Texture::for_pixbuf(&sc); + let img = gtk4::Image::from_paintable(Some(&tex)); + btn.set_child(Some(&img)); + } + } + }); + + // ── Iniciar Playlist ── + let s = state.clone(); + btn_start.connect_clicked(move |_| { + start_next_track(s.clone(), 0); + }); + + // ── Siguiente tema ── + let s = state.clone(); + btn_next.connect_clicked(move |_| { + // Interrumpir streaming en curso si lo hay + s.lock().unwrap().stream_skip = true; + let next_idx = { + let st = s.lock().unwrap(); + if st.playlist.is_empty() { + return; + } + st.current_index % st.playlist.len() + }; + start_next_track(s.clone(), next_idx); + }); + + // ── Timer de actualización UI (cada 500ms) ── + let s = state.clone(); + let title_a_c = title_a.clone(); + let title_b_c = title_b.clone(); + let time_a_c = time_a.clone(); + let time_b_c = time_b.clone(); + let total_a_c = total_a.clone(); + let total_b_c = total_b.clone(); + let adj_a_c = adj_a.clone(); + let adj_b_c = adj_b.clone(); + let clock_c = clock_label.clone(); + let status_c = status_label.clone(); + let lbl_station_c = lbl_station.clone(); + let now_playing_c = now_playing.clone(); + let vol_info_c = vol_info.clone(); + let btn_start_c = btn_start.clone(); + let btn_ia_c = btn_ia.clone(); + let mut last_gr_on = false; + + // Clones para el timer + let playlist_listbox_c = playlist_listbox.clone(); + let espera_listbox_c = espera_listbox.clone(); + let eventos_listbox_c = eventos_listbox.clone(); + let tandas_box_c = tandas_box.clone(); + let paned_eventos_c = paned_eventos.clone(); + let comm_title_c = comm_title.clone(); + let comm_time_c = comm_time.clone(); + let comm_total_c = comm_total.clone(); + let comm_adj_c = comm_adj.clone(); + let deck_a_frame_c = deck_a_frame.clone(); + let deck_b_frame_c = deck_b_frame.clone(); + let comm_bar_c = comm_bar.clone(); + + // Reload vol files periodically + let home_c = home.clone(); + let win_c = window.clone(); + + // Clones para el VU meter + let vu_bar_l_c = vu_bar_l.clone(); + let vu_bar_r_c = vu_bar_r.clone(); + let vu_level_l_c = vu_level_l_draw.clone(); + let vu_level_r_c = vu_level_r_draw.clone(); + let vu_active_c = vu_active_draw.clone(); + + // Clones para botones "Detener al final" (para actualizar CSS desde el timer) + let btn_stop_end_a_c = btn_stop_end_a.clone(); + let btn_stop_end_b_c = btn_stop_end_b.clone(); + + let lbl_cliente_c = lbl_cliente.clone(); + let cliente_conectado_c = cliente_conectado.clone(); + let lbl_total_dur_c = lbl_total_dur.clone(); + let mut last_playlist_version: u64 = 0; + let mut last_playlist_signature: String = String::new(); + let mut last_playlist_mtime = std::time::SystemTime::UNIX_EPOCH; + let mut last_current_song = String::new(); + // Snapshot del contenido de cada listbox de cola (no del archivo) — se usa para + // detectar si cambió y rearmar las filas con duración + botones up/down. + let mut last_signature_espera: String = String::new(); + let mut last_signature_evt: String = String::new(); + let mut last_sig_comlist: String = String::new(); // watcher cola activa + let mut upcoming_refresh_counter: u32 = 300; // forzar refresh inmediato + // Último tooltip aplicado a cada título de deck — set_tooltip_text() repetido + // en cada tick (incluso con el mismo valor) reinicia el temporizador de hover + // de GTK para TODA la ventana, matando los tooltips de toda la app. Solo se + // debe llamar cuando el valor realmente cambia. + let mut last_tooltip_a: Option = None; + let mut last_tooltip_b: Option = None; + let mut last_tooltip_now: Option = None; + timeout_add_local(Duration::from_millis(200), move || { + // Reloj + clock_c.set_text(&Local::now().format("%H:%M:%S").to_string()); + + // ── Cliente remoto conectado ────────────────────────────────────────── + { + let texto = cliente_conectado_c.lock().unwrap().clone(); + lbl_cliente_c.set_text(&texto); + } + + // ── Play de tanda comercial (IPC vía archivo cmd_play_tanda) ───────────── + // Escenario 1: tema en reproducción → fadeout → tanda → playlist + // Escenario 2: detenido (stop_after_track ya disparó) → tanda → playlist + { + let cmd_tanda = home_c.join(".gradio/data/tmp/cmd_play_tanda"); + if cmd_tanda.exists() { + let _ = fs::remove_file(&cmd_tanda); + let state_t = s.clone(); + let home_t = home_c.clone(); + glib::spawn_future_local(async move { + let comerc_path = home_t.join(".gradio/data/tmp/comercialeslist4"); + let commercials = load_commercials(&comerc_path); + if !commercials.is_empty() { + // Fadeout del deck activo antes de la tanda + let crossfade = { state_t.lock().unwrap().crossfade_secs }; + fadeout_active_deck(&state_t, crossfade).await; + let idx = { state_t.lock().unwrap().current_index }; + play_commercials_then_track(state_t, commercials, vec![], idx).await; + } + }); + return glib::ControlFlow::Continue; + } + } + + // ── Botonera remota (IPC vía archivo botonera_now) ─────────────────────── + { + let botonera_now_file = home_c.join(".gradio/data/tmp/botonera_now"); + if let Ok(ruta) = fs::read_to_string(&botonera_now_file) { + let _ = fs::remove_file(&botonera_now_file); + let ruta = ruta.trim().to_string(); + if !ruta.is_empty() { + let state_b = s.clone(); + glib::spawn_future_local(async move { + play_botonera_file(&state_b, &ruta).await; + }); + return glib::ControlFlow::Continue; + } + } + } + + // ── Play Now desde el buscador (IPC vía archivo play_now) ──────────────── + { + let play_now_file = home_c.join(".gradio/data/tmp/play_now"); + if let Ok(content) = fs::read_to_string(&play_now_file) { + let _ = fs::remove_file(&play_now_file); + let content = content.trim().to_string(); + if !content.is_empty() { + // Formato: "/ruta/archivo.mp3\tHH:MM:SS" + let mut parts = content.splitn(2, '\t'); + let path_str = parts.next().unwrap_or("").trim().to_string(); + let dur_str = parts.next().unwrap_or("00:00:00").trim().to_string(); + if !path_str.is_empty() { + let track_path = PathBuf::from(&path_str); + let title = track_path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Audio") + .to_string(); + // Convertir HH:MM:SS → segundos + let dur_secs: f64 = { + let p: Vec<&str> = dur_str.splitn(3, ':').collect(); + match p.as_slice() { + [h, m, s] => { + let hv: f64 = h.parse().unwrap_or(0.0); + let mv: f64 = m.parse().unwrap_or(0.0); + let sv: f64 = s.parse().unwrap_or(0.0); + hv * 3600.0 + mv * 60.0 + sv + } + [m, s] => { + let mv: f64 = m.parse().unwrap_or(0.0); + let sv: f64 = s.parse().unwrap_or(0.0); + mv * 60.0 + sv + } + _ => 0.0, + } + }; + let track = Track { path: track_path.clone(), duration_secs: dur_secs, title }; + // Insertar al frente de la playlist en memoria y en archivo + { + let mut st2 = s.lock().unwrap(); + st2.playlist.insert(0, track); + st2.playlist_version = st2.playlist_version.wrapping_add(1); + st2.crossfade_triggered = false; + // Prepend al archivo playlist4 + let playlist_path = home_c.join(".gradio/data/tmp/playlist4"); + let existing = fs::read_to_string(&playlist_path).unwrap_or_default(); + let new_line = format!("{}\t{}\n", track_path.display(), dur_str); + let _ = fs::write(&playlist_path, format!("{}{}", new_line, existing)); + } + info!("play_now: crossfade hacia '{}'", path_str); + start_next_track(s.clone(), 0); + return glib::ControlFlow::Continue; + } + } + } + } + + // ── Comandos remotos de gr-client ────────────────────────────────────── + { + let tmp = home_c.join(".gradio/data/tmp"); + for cmd in ["cmd_play", "cmd_pause", "cmd_stop", "cmd_stop_after", + "cmd_siguiente", "cmd_stop_general"] { + let f = tmp.join(cmd); + if f.exists() { + let _ = fs::remove_file(&f); + match cmd { + "cmd_play" => { + let mut st = s.lock().unwrap(); + action_play(&mut st, ActiveDeck::A); + } + "cmd_pause" => { + let mut st = s.lock().unwrap(); + action_pause(&mut st, ActiveDeck::A); + } + "cmd_stop" => { + let mut st = s.lock().unwrap(); + action_stop(&mut st, ActiveDeck::A); + } + "cmd_stop_after" => { + let mut st = s.lock().unwrap(); + st.stop_after_track = !st.stop_after_track; + } + "cmd_siguiente" => { + s.lock().unwrap().stream_skip = true; + let next_idx = { + let st = s.lock().unwrap(); + if st.playlist.is_empty() { continue; } + st.current_index % st.playlist.len() + }; + start_next_track(s.clone(), next_idx); + } + "cmd_stop_general" => { + interrupt_pautaje(&s); + let mut st = s.lock().unwrap(); + action_stop(&mut st, ActiveDeck::A); + action_stop(&mut st, ActiveDeck::B); + info!("Stop General activado por comando remoto"); + } + _ => {} + } + } + } + } + + let mut st = s.lock().unwrap(); + + // ── Detección de suspensión del GLib main loop (pantalla en blanco ARM/Wayland) ── + // En GTK4+Wayland, cuando el compositor apaga la pantalla puede congelar el main + // loop. Al reanudar, `elapsed()` acumula todo el tiempo de pausa y los detectores + // de silencio / drift disparan en falso. Si el gap entre ticks supera 2s + // reseteamos los contadores para evitar avance de tema incorrecto. + { + let timer_gap = st.last_timer_tick.elapsed().as_secs_f64(); + if timer_gap > 2.0 { + warn!("Timer gap {:.1}s detectado — reseteando detectores de silencio (ARM/screen-blank)", timer_gap); + st.silence_since_a = None; + st.stuck_since_a = None; + st.stuck_pos_a = -1.0; + st.silence_since_b = None; + st.stuck_since_b = None; + st.stuck_pos_b = -1.0; + // Actualizar last_level_msg para que Via 1 (nivel) no lo considere stale + st.vu_last_level_msg = std::time::Instant::now(); + } + st.last_timer_tick = std::time::Instant::now(); + } + + // Re-leer volúmenes desde archivos + let new_upvol = read_f64_from_file(&home_c.join(".gradio/data/tmp/upvol")).clamp(0.0, 100.0) / 100.0; + let new_downvol = read_f64_from_file(&home_c.join(".gradio/data/tmp/downvol")).clamp(0.0, 100.0) / 100.0; + + if (new_upvol - st.upvol).abs() > 0.005 { + st.upvol = new_upvol; + if !st.duck_active { + for p in [&st.pipeline_a, &st.pipeline_b].into_iter().flatten() { + set_pipeline_volume(p, new_upvol); + } + } + } + if (new_downvol - st.downvol).abs() > 0.005 { + st.downvol = new_downvol; + } + + // Re-leer configuración en cada ciclo (por si cambió desde el panel) + let gr_cfg_now = read_gradio_config(); + st.crossfade_secs = gr_cfg_now.crossfade_secs; + st.silence_secs = gr_cfg_now.silence_secs; + st.station_name = gr_cfg_now.station_name.clone(); + + vol_info_c.set_text(&i18n::tr("vol.fmt") + .replace("{up}", &format!("{:.0}", st.upvol * 100.0)) + .replace("{dn}", &format!("{:.0}", st.downvol * 100.0)) + .replace("{mix}", &format!("{:.1}", st.crossfade_secs))); + btn_ia_c.set_visible(gr_cfg_now.ia_habilitada); + + // ── Ícono GR-On / GR-Off según estado de reproducción ── + let is_playing = st.deck_a_state == PlaybackState::Playing + || st.deck_b_state == PlaybackState::Playing; + if is_playing != last_gr_on { + last_gr_on = is_playing; + let icon_bytes = if is_playing { icon_gr_on() } else { icon_gr_off() }; + set_button_icon(&btn_start_c, &icon_bytes, 36); + } + + // ── Detección de eventos emergentes ── + // Si eventoslist tiene contenido y no estamos ya procesándolo → lanzar + if !st.eventos_playing && !st.commercials_playing { + let eventos_path = home_c.join(".gradio/data/tmp/eventoslist"); + let tiene_eventos = fs::read_to_string(&eventos_path) + .map(|s| s.lines().any(|l| !l.trim().is_empty())) + .unwrap_or(false); + if tiene_eventos { + st.eventos_playing = true; + let state_ev = s.clone(); + drop(st); + glib::spawn_future_local(async move { + play_eventos_emergentes(state_ev).await; + }); + return glib::ControlFlow::Continue; + } + } + + // ── Actualizar barra de comerciales ── + if let Some(ref p) = st.pipeline_comm { + let pos = pipeline_position(p); + let dur = pipeline_duration(p).max(1.0); + comm_time_c.set_text(&secs_to_display(pos)); + comm_total_c.set_text(&secs_to_display(dur)); + comm_adj_c.set_upper(dur); + comm_adj_c.set_value(pos); + // Mostrar nombre del audio en curso en la barra superior y en comm_title + if !st.comm_title.is_empty() { + now_playing_c.set_text(&format!("📢 {}", st.comm_title)); + comm_title_c.set_text(&st.comm_title); + } + } else { + comm_time_c.set_text("00:00"); + comm_total_c.set_text("00:00"); + comm_adj_c.set_upper(100.0); + comm_adj_c.set_value(0.0); + // Limpiar título y current_title cuando no hay nada reproduciendo + if !st.comm_title.is_empty() { + st.comm_title = String::new(); + } + comm_title_c.set_text(i18n::tr("now.no_ad")); + } + + // ── Players: mostrar solo el panel correspondiente al modo elegido ── + if players_mode != 3 { + let comercial_activo = players_mode == 1 && st.pipeline_comm.is_some(); + if comercial_activo { + deck_a_frame_c.set_visible(false); + deck_b_frame_c.set_visible(false); + comm_bar_c.set_visible(true); + } else { + let is_a = st.active_deck == ActiveDeck::A; + deck_a_frame_c.set_visible(is_a); + deck_b_frame_c.set_visible(!is_a); + if players_mode == 1 { + comm_bar_c.set_visible(false); + } + } + } + + // ── Actualizar barras VU desde AppState (llenado por bus_watch) ────────── + let (lev_l, lev_r) = if st.vu_active { + st.vu_level_l = (st.vu_level_l * 0.85).max(0.0); + st.vu_level_r = (st.vu_level_r * 0.85).max(0.0); + vu_active_c.set(true); + (st.vu_level_l, st.vu_level_r) + } else { + st.vu_level_l = 0.0; + st.vu_level_r = 0.0; + vu_active_c.set(false); + (0.0f64, 0.0f64) + }; + vu_level_l_c.set(lev_l); + vu_level_r_c.set(lev_r); + vu_bar_l_c.queue_draw(); + vu_bar_r_c.queue_draw(); + + // ── Actualizar CSS botón "Detener al final" ── + for btn in [&btn_stop_end_a_c, &btn_stop_end_b_c] { + if st.stop_after_track { + if !btn.has_css_class("deck-icon-btn-stop") { + btn.remove_css_class("deck-icon-btn"); + btn.add_css_class("deck-icon-btn-stop"); + } + } else { + if btn.has_css_class("deck-icon-btn-stop") { + btn.remove_css_class("deck-icon-btn-stop"); + btn.add_css_class("deck-icon-btn"); + } + } + } + + // ── Actualizar Deck A ── + { + let (pos, dur, bus_opt, has_pipeline) = if let Some(ref p) = st.pipeline_a { + (pipeline_position(p), pipeline_duration(p).max(1.0), p.bus(), true) + } else { + // Sin pipeline: reset visual a cero + if st.stopped_after_a { + title_a_c.set_text(i18n::tr("now.stopped_after")); + } else { + title_a_c.set_text(i18n::tr("now.no_track")); + } + if last_tooltip_a.is_some() { + last_tooltip_a = None; + title_a_c.set_tooltip_text(None::<&str>); + } + time_a_c.set_text("00:00"); + total_a_c.set_text("00:00"); + adj_a_c.set_upper(100.0); + adj_a_c.set_value(0.0); + (0.0, 0.0, None, false) + }; + if has_pipeline { + time_a_c.set_text(&secs_to_display(pos)); + total_a_c.set_text(&secs_to_display(dur)); + adj_a_c.set_upper(dur); + adj_a_c.set_value(pos); + } + // ── Detector de silencio / posición estancada — Deck A ─────────────── + if has_pipeline + && st.active_deck == ActiveDeck::A + && st.deck_a_state == PlaybackState::Playing + && !st.commercials_playing + && !st.eventos_playing + && !st.crossfade_triggered + && st.silence_secs > 0.0 + { + // ── Vía 1: nivel de audio bajo -45 dB (solo si pos es válida) ── + if pos > 2.0 { + let level_stale = st.vu_last_level_msg.elapsed().as_secs_f64() > 1.5; + let is_silent = level_stale + || (st.vu_level_l_raw < -45.0 && st.vu_level_r_raw < -45.0); + if is_silent { + if st.silence_since_a.is_none() { + st.silence_since_a = Some(std::time::Instant::now()); + } else if st.silence_since_a.unwrap().elapsed().as_secs_f64() >= st.silence_secs { + warn!("Deck A: silencio sostenido {:.0}s — {}", st.silence_secs, + if st.loop_deck_a { "reiniciando (loop)" } else if st.stop_after_track { "deteniendo" } else { "avanzando" }); + st.silence_since_a = None; st.stuck_since_a = None; + if st.loop_deck_a { + if let Some(ref p) = st.pipeline_a { + let _ = p.set_state(gst::State::Ready); + let _ = p.set_state(gst::State::Playing); + } + st.current_track_start = std::time::Instant::now(); + } else if st.stop_after_track { + st.stop_after_track = false; + st.stopped_after_a = true; + action_stop(&mut st, ActiveDeck::A); + now_playing_c.set_text(i18n::tr("now.stopped_end")); + return glib::ControlFlow::Continue; + } else { + st.crossfade_triggered = true; + let next = st.current_index % st.playlist.len().max(1); + drop(st); + start_next_track(s.clone(), next); + return glib::ControlFlow::Continue; + } + } + } else { + st.silence_since_a = None; + } + } + // ── Vía 2: reloj de pared vs posición GStreamer ── + // Si el reloj real lleva silence_secs+ por delante de pos → pipeline pegado. + // GUARD pos > 0: si query_position devuelve None (→0.0), p.ej. al despertar + // de pantalla en blanco en ARM, el drift sería enorme y dispararía en falso. + let wall = st.current_track_start.elapsed().as_secs_f64(); + let drift = wall - pos.max(0.0); + if pos > 0.0 && wall > 8.0 && drift >= st.silence_secs { + warn!("Deck A: reloj={:.1}s pos={:.1}s drift={:.1}s — {}", + wall, pos, drift, + if st.loop_deck_a { "reiniciando (loop)" } else if st.stop_after_track { "deteniendo" } else { "avanzando" }); + st.stuck_since_a = None; st.silence_since_a = None; + if st.loop_deck_a { + if let Some(ref p) = st.pipeline_a { + let _ = p.set_state(gst::State::Ready); + let _ = p.set_state(gst::State::Playing); + } + st.current_track_start = std::time::Instant::now(); + } else if st.stop_after_track { + st.stop_after_track = false; + st.stopped_after_a = true; + action_stop(&mut st, ActiveDeck::A); + now_playing_c.set_text(i18n::tr("now.stopped_end")); + return glib::ControlFlow::Continue; + } else { + st.crossfade_triggered = true; + let next = st.current_index % st.playlist.len().max(1); + drop(st); + start_next_track(s.clone(), next); + return glib::ControlFlow::Continue; + } + } + } else { + st.silence_since_a = None; + st.stuck_since_a = None; + st.stuck_pos_a = -1.0; + } + if let Some(bus) = bus_opt { + // Cruzar por posición: lanzar siguiente deck cuando quedan crossfade_secs. + // Guardia de 500ms: evita disparo falso si GStreamer aún reporta posición + // antigua tras un seek (p.ej. después de pulsar Repetir). + if has_pipeline + && st.active_deck == ActiveDeck::A + && !st.crossfade_triggered + && !st.commercials_playing + && !st.eventos_playing + && !st.stop_after_track + && dur > st.crossfade_secs.max(1.0) + && pos > 0.0 + && st.current_track_start.elapsed().as_secs_f64() > 0.5 + && (dur - pos) <= st.crossfade_secs.max(0.1) + { + if !st.loop_deck_a { + let next = st.current_index % st.playlist.len().max(1); + let next_path = st.playlist.get(next).map(|t| t.path.clone()).unwrap_or_default(); + let sin_fundir = fundido_omitido(&read_gradio_config(), &st.current_track_path, &next_path); + if !sin_fundir { + st.crossfade_triggered = true; + drop(st); + start_next_track(s.clone(), next); + return glib::ControlFlow::Continue; + } + // sin_fundir: carpeta excluida de crossfade — dejar llegar al + // EOS natural para no cortar el final del audio, el adelanto + // por posición sólo tiene sentido si se va a cruzar. + } + // loop_deck_a activo: dejar llegar al EOS natural, no hacer crossfade + } + // EOS o Error: avanzar al siguiente track + let mut advance_deck_a = false; + while let Some(msg) = bus.pop() { + use gst::MessageView; + match msg.view() { + MessageView::Eos(_) => { + if st.active_deck == ActiveDeck::A { advance_deck_a = true; } + } + MessageView::Error(e) => { + if st.active_deck == ActiveDeck::A { + warn!("Pipeline A error: {} — avanzando al siguiente", e.error()); + advance_deck_a = true; + // Liberar pipeline para evitar errores repetidos en bus + if let Some(p) = st.pipeline_a.take() { + shutdown_pipeline(p); + } + break; + } + } + _ => {} + } + } + if advance_deck_a { + if st.loop_deck_a { + // Loop: llevar a Ready reinicia todos los elementos (EOS queda limpio), + // luego Playing vuelve a reproducir desde el inicio. + if let Some(ref p) = st.pipeline_a { + let _ = p.set_state(gst::State::Ready); + let _ = p.set_state(gst::State::Playing); + } + } else if st.stop_after_track { + st.stop_after_track = false; + st.stopped_after_a = true; + action_stop(&mut st, ActiveDeck::A); + now_playing_c.set_text(i18n::tr("now.stopped_end")); + } else if !st.crossfade_triggered { + st.crossfade_triggered = true; + let next = st.current_index % st.playlist.len().max(1); + drop(st); + start_next_track(s.clone(), next); + return glib::ControlFlow::Continue; + } + } + } + } + + // ── Actualizar Deck B ── + { + let (pos, dur, bus_opt, has_pipeline) = if let Some(ref p) = st.pipeline_b { + (pipeline_position(p), pipeline_duration(p).max(1.0), p.bus(), true) + } else { + if st.stopped_after_b { + title_b_c.set_text(i18n::tr("now.stopped_after")); + } else { + title_b_c.set_text(i18n::tr("now.no_track")); + } + if last_tooltip_b.is_some() { + last_tooltip_b = None; + title_b_c.set_tooltip_text(None::<&str>); + } + time_b_c.set_text("00:00"); + total_b_c.set_text("00:00"); + adj_b_c.set_upper(100.0); + adj_b_c.set_value(0.0); + (0.0, 0.0, None, false) + }; + if has_pipeline { + time_b_c.set_text(&secs_to_display(pos)); + total_b_c.set_text(&secs_to_display(dur)); + adj_b_c.set_upper(dur); + adj_b_c.set_value(pos); + } + // ── Detector de silencio / posición estancada — Deck B ─────────────── + if has_pipeline + && st.active_deck == ActiveDeck::B + && st.deck_b_state == PlaybackState::Playing + && !st.commercials_playing + && !st.eventos_playing + && !st.crossfade_triggered + && st.silence_secs > 0.0 + { + // ── Vía 1: nivel de audio bajo -45 dB (solo si pos es válida) ── + if pos > 2.0 { + let level_stale = st.vu_last_level_msg.elapsed().as_secs_f64() > 1.5; + let is_silent = level_stale + || (st.vu_level_l_raw < -45.0 && st.vu_level_r_raw < -45.0); + if is_silent { + if st.silence_since_b.is_none() { + st.silence_since_b = Some(std::time::Instant::now()); + } else if st.silence_since_b.unwrap().elapsed().as_secs_f64() >= st.silence_secs { + warn!("Deck B: silencio sostenido {:.0}s — {}", st.silence_secs, + if st.loop_deck_b { "reiniciando (loop)" } else if st.stop_after_track { "deteniendo" } else { "avanzando" }); + st.silence_since_b = None; st.stuck_since_b = None; + if st.loop_deck_b { + if let Some(ref p) = st.pipeline_b { + let _ = p.set_state(gst::State::Ready); + let _ = p.set_state(gst::State::Playing); + } + st.current_track_start = std::time::Instant::now(); + } else if st.stop_after_track { + st.stop_after_track = false; + st.stopped_after_b = true; + action_stop(&mut st, ActiveDeck::B); + now_playing_c.set_text(i18n::tr("now.stopped_end")); + return glib::ControlFlow::Continue; + } else { + st.crossfade_triggered = true; + let next = st.current_index % st.playlist.len().max(1); + drop(st); + start_next_track(s.clone(), next); + return glib::ControlFlow::Continue; + } + } + } else { + st.silence_since_b = None; + } + } + // ── Vía 2: reloj de pared vs posición GStreamer ── + // Guard pos > 0: igual que Deck A, evita falso positivo al despertar pantalla. + let wall = st.current_track_start.elapsed().as_secs_f64(); + let drift = wall - pos.max(0.0); + if pos > 0.0 && wall > 8.0 && drift >= st.silence_secs { + warn!("Deck B: reloj={:.1}s pos={:.1}s drift={:.1}s — {}", + wall, pos, drift, + if st.loop_deck_b { "reiniciando (loop)" } else if st.stop_after_track { "deteniendo" } else { "avanzando" }); + st.stuck_since_b = None; st.silence_since_b = None; + if st.loop_deck_b { + if let Some(ref p) = st.pipeline_b { + let _ = p.set_state(gst::State::Ready); + let _ = p.set_state(gst::State::Playing); + } + st.current_track_start = std::time::Instant::now(); + } else if st.stop_after_track { + st.stop_after_track = false; + st.stopped_after_b = true; + action_stop(&mut st, ActiveDeck::B); + now_playing_c.set_text(i18n::tr("now.stopped_end")); + return glib::ControlFlow::Continue; + } else { + st.crossfade_triggered = true; + let next = st.current_index % st.playlist.len().max(1); + drop(st); + start_next_track(s.clone(), next); + return glib::ControlFlow::Continue; + } + } + } else { + st.silence_since_b = None; + st.stuck_since_b = None; + st.stuck_pos_b = -1.0; + } + if let Some(bus) = bus_opt { + // Cruzar por posición: lanzar siguiente deck cuando quedan crossfade_secs. + // Guardia de 500ms igual que deck A. + if has_pipeline + && st.active_deck == ActiveDeck::B + && !st.crossfade_triggered + && !st.commercials_playing + && !st.eventos_playing + && !st.stop_after_track + && dur > st.crossfade_secs.max(1.0) + && pos > 0.0 + && st.current_track_start.elapsed().as_secs_f64() > 0.5 + && (dur - pos) <= st.crossfade_secs.max(0.1) + { + if !st.loop_deck_b { + let next = st.current_index % st.playlist.len().max(1); + let next_path = st.playlist.get(next).map(|t| t.path.clone()).unwrap_or_default(); + let sin_fundir = fundido_omitido(&read_gradio_config(), &st.current_track_path, &next_path); + if !sin_fundir { + st.crossfade_triggered = true; + drop(st); + start_next_track(s.clone(), next); + return glib::ControlFlow::Continue; + } + // sin_fundir: carpeta excluida de crossfade — dejar llegar al + // EOS natural para no cortar el final del audio, el adelanto + // por posición sólo tiene sentido si se va a cruzar. + } + // loop_deck_b activo: dejar llegar al EOS natural, no hacer crossfade + } + // EOS o Error: avanzar al siguiente track + let mut advance_deck_b = false; + while let Some(msg) = bus.pop() { + use gst::MessageView; + match msg.view() { + MessageView::Eos(_) => { + if st.active_deck == ActiveDeck::B { advance_deck_b = true; } + } + MessageView::Error(e) => { + if st.active_deck == ActiveDeck::B { + warn!("Pipeline B error: {} — avanzando al siguiente", e.error()); + advance_deck_b = true; + // Liberar pipeline para evitar errores repetidos en bus + if let Some(p) = st.pipeline_b.take() { + shutdown_pipeline(p); + } + break; + } + } + _ => {} + } + } + if advance_deck_b { + if st.loop_deck_b { + // Loop: llevar a Ready reinicia todos los elementos (EOS queda limpio), + // luego Playing vuelve a reproducir desde el inicio. + if let Some(ref p) = st.pipeline_b { + let _ = p.set_state(gst::State::Ready); + let _ = p.set_state(gst::State::Playing); + } + } else if st.stop_after_track { + st.stop_after_track = false; + st.stopped_after_b = true; + action_stop(&mut st, ActiveDeck::B); + now_playing_c.set_text(i18n::tr("now.stopped_end")); + st.current_title = String::new(); + } else if !st.crossfade_triggered { + st.crossfade_triggered = true; + let next = st.current_index % st.playlist.len().max(1); + drop(st); + start_next_track(s.clone(), next); + return glib::ControlFlow::Continue; + } + } + } + } + + // Actualizar títulos — pipeline_comm tiene prioridad (ya se actualizó arriba). + // Solo actualizamos con current_title si NO hay pipeline_comm activo. + if st.pipeline_comm.is_none() { + let has_music = st.pipeline_a.is_some() || st.pipeline_b.is_some(); + if has_music { + let title = if !st.current_title.is_empty() { + st.current_title.clone() + } else if let Some(t) = st.playlist.get(st.current_index) { + t.title.clone() + } else { + String::new() + }; + if !title.is_empty() { + now_playing_c.set_text(&format!("▶ {}", title)); + let path_tip = st.current_track_path.to_str().unwrap_or("").to_string(); + let tip_val = if path_tip.is_empty() { None } else { Some(path_tip) }; + if last_tooltip_now != tip_val { + now_playing_c.set_tooltip_text(tip_val.as_deref()); + last_tooltip_now = tip_val.clone(); + } + match st.active_deck { + ActiveDeck::A => { + title_a_c.set_text(&title); + if last_tooltip_a != tip_val { + title_a_c.set_tooltip_text(tip_val.as_deref()); + last_tooltip_a = tip_val; + } + } + ActiveDeck::B => { + title_b_c.set_text(&title); + if last_tooltip_b != tip_val { + title_b_c.set_tooltip_text(tip_val.as_deref()); + last_tooltip_b = tip_val; + } + } + } + status_c.set_text(&format!("▶ {} temas en cola", st.playlist.len())); + lbl_station_c.set_text(&st.station_name); + } + } else { + // Sin música ni comerciales: limpiar etiquetas + if st.stopped_after_a || st.stopped_after_b { + now_playing_c.set_text(i18n::tr("now.stopped_end")); + } else { + now_playing_c.set_text(i18n::tr("now.app_name")); + } + if last_tooltip_now.is_some() { + last_tooltip_now = None; + now_playing_c.set_tooltip_text(None::<&str>); + } + if !st.stopped_after_a { title_a_c.set_text(i18n::tr("now.no_track")); } + if !st.stopped_after_b { title_b_c.set_text(i18n::tr("now.no_track")); } + st.current_title = String::new(); + status_c.set_text(&format!("{} temas en cola", st.playlist.len())); + lbl_station_c.set_text(&st.station_name); + } + } + + // ── Actualizar CurrentSong.txt ──────────────────────────────────────── + { + let song = if st.pipeline_comm.is_some() && !st.comm_title.is_empty() { + st.comm_title.clone() + } else if !st.current_title.is_empty() { + st.current_title.clone() + } else { + String::new() + }; + if !song.is_empty() && song != last_current_song { + last_current_song = song.clone(); + let _ = fs::write(home_c.join("CurrentSong.txt"), &song); + } + } + + // ── Actualizar panel de playlist ── + // Sincronizar con archivo si un módulo externo lo modificó (gr-client, + // APK, playlist-refill). Se compara mtime para detectar también + // reordenamientos remotos que no cambian el número de líneas. + { + let playlist_path = home_c.join(".gradio/data/tmp/playlist4"); + if let Ok(mtime) = fs::metadata(&playlist_path).and_then(|m| m.modified()) { + if mtime != last_playlist_mtime { + last_playlist_mtime = mtime; + let new_tracks = load_playlist(&playlist_path); + st.playlist = new_tracks; + st.playlist_version = st.playlist_version.wrapping_add(1); + } + } + } + + // Tomar snapshot ANTES de redibujar (sin soltar st) + // Reconstruir sólo si el contenido visible cambió: comparamos un + // signature (path + duración por track) en vez del version_now — + // playlist-refill reescribe playlist4 frecuentemente sin alterar el + // contenido, y rebuildear destruía los tooltips de los botones inline + // antes de que GTK los pudiera mostrar (~500ms de hover). + let n_filas = st.playlist.len() as u32; + let current_rows = playlist_listbox_c.observe_children().n_items(); + let _version_now = st.playlist_version; + last_playlist_version = _version_now; + let signature_now: String = st.playlist.iter() + .map(|t| format!("{}|{}", t.path.to_string_lossy(), t.duration_secs)) + .collect::>() + .join("\x1f"); + let needs_rebuild = current_rows != n_filas || signature_now != last_playlist_signature; + let tracks_snap: Vec<(usize, String, String, f64)> = if needs_rebuild { + last_playlist_signature = signature_now; + st.playlist.iter().enumerate() + .map(|(i, t)| (i, t.title.clone(), t.path.to_string_lossy().to_string(), t.duration_secs)) + .collect() + } else { + Vec::new() + }; + let total_secs: f64 = st.playlist.iter().map(|t| t.duration_secs).sum(); + drop(st); // liberar lock: los widgets se crean sin el Mutex tomado + + if needs_rebuild { + while let Some(child) = playlist_listbox_c.first_child() { + playlist_listbox_c.remove(&child); + } + lbl_total_dur_c.set_text(&format!("Total: {}", secs_to_display(total_secs))); + + for (idx, title, path_str, dur_secs) in &tracks_snap { + let idx = *idx; + + let row_box = GtkBox::new(Orientation::Horizontal, 4); + + // Botón numérico azul: click simple reproduce con crossfade + let idx_btn = Button::with_label(&format!("{}", idx + 1)); + idx_btn.add_css_class("queue-idx-btn"); + { + let s_btn = s.clone(); + idx_btn.connect_clicked(move |_| { + let target = { + let st = s_btn.lock().unwrap(); + match st.active_deck { + ActiveDeck::A => ActiveDeck::B, + ActiveDeck::B => ActiveDeck::A, + } + }; + play_track_to_deck(s_btn.clone(), idx, target); + }); + } + + let title_lbl = Label::new(Some(title.as_str())); + title_lbl.set_halign(gtk4::Align::Start); + title_lbl.set_hexpand(true); + title_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + // Tooltip con la ruta completa al pasar el mouse + title_lbl.set_tooltip_text(Some(path_str.as_str())); + row_box.append(&idx_btn); + row_box.append(&title_lbl); + + // ── Duración del tema ──────────────────────────────────────── + let dur_lbl = Label::new(Some(&secs_to_display(*dur_secs))); + dur_lbl.add_css_class("queue-dur-label"); + dur_lbl.set_halign(gtk4::Align::End); + row_box.append(&dur_lbl); + + // ── Botones inline: subir / bajar / eliminar ───────────────── + let n_tracks = tracks_snap.len(); + + let btn_qi_up = icon_button(&icon_queue_up(), "Subir en la cola", 28); + let btn_qi_down = icon_button(&icon_queue_down(), "Bajar en la cola", 28); + let btn_qi_del = icon_button(&icon_queue_trash(), "Eliminar de la cola", 28); + btn_qi_up.add_css_class("queue-action-btn"); + btn_qi_down.add_css_class("queue-action-btn"); + btn_qi_del.add_css_class("queue-action-btn"); + btn_qi_up.set_sensitive(idx > 0); + btn_qi_down.set_sensitive(idx + 1 < n_tracks); + + { + let s2 = s.clone(); + let pp = home_c.join(".gradio/data/tmp/playlist4"); + btn_qi_up.connect_clicked(move |_| { + let mut st = s2.lock().unwrap(); + if idx > 0 && idx < st.playlist.len() { + st.playlist.swap(idx, idx - 1); + save_playlist_to_file(&st.playlist, &pp); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + }); + } + { + let s2 = s.clone(); + let pp = home_c.join(".gradio/data/tmp/playlist4"); + btn_qi_down.connect_clicked(move |_| { + let mut st = s2.lock().unwrap(); + if idx + 1 < st.playlist.len() { + st.playlist.swap(idx, idx + 1); + save_playlist_to_file(&st.playlist, &pp); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + }); + } + { + let s2 = s.clone(); + let pp = home_c.join(".gradio/data/tmp/playlist4"); + btn_qi_del.connect_clicked(move |_| { + let mut st = s2.lock().unwrap(); + if idx < st.playlist.len() { + st.playlist.remove(idx); + save_playlist_to_file(&st.playlist, &pp); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + }); + } + + row_box.append(&btn_qi_up); + row_box.append(&btn_qi_down); + row_box.append(&btn_qi_del); + + let row = ListBoxRow::new(); + row.set_child(Some(&row_box)); + if idx == 0 { + row.add_css_class("playing-row"); + } + + // ── DragSource: arrastrar para reordenar O soltar sobre un deck ── + { + let drag_src = DragSource::new(); + drag_src.set_actions(gdk4::DragAction::MOVE); + let payload = format!("playlist-row:{}", idx); + drag_src.connect_prepare(move |_src, _x, _y| { + Some(ContentProvider::for_value(&payload.clone().to_value())) + }); + row.add_controller(drag_src); + } + + // ── Botón derecho: ventana flotante de menú contextual ── + { + let s_hora = s.clone(); + let s_stream = s.clone(); + let pp_hora = home_c.join(".gradio/data/tmp/playlist4"); + let pp_stream = home_c.join(".gradio/data/tmp/playlist4"); + let win_ref = win_c.clone(); + + // Popover posicionado sobre el cursor — anclado al row_box + let popover = gtk4::Popover::new(); + popover.set_parent(&row_box); + popover.set_has_arrow(false); + popover.set_autohide(true); + + let vbox = GtkBox::new(Orientation::Vertical, 0); + vbox.add_css_class("context-menu-box"); + vbox.set_margin_top(2); + vbox.set_margin_bottom(2); + vbox.set_margin_start(2); + vbox.set_margin_end(2); + + let btn_cue = Button::with_label(i18n::tr("ctx.cue")); + let btn_hora = Button::with_label(i18n::tr("ctx.ins_hora")); + let btn_stream = Button::with_label(i18n::tr("ctx.ins_stream")); + let btn_load_list = Button::with_label(i18n::tr("ctx.load_list")); + let btn_regen = Button::with_label(i18n::tr("ctx.regen")); + let btn_del = Button::with_label(i18n::tr("ctx.del_cola")); + btn_cue.add_css_class("context-menu-item"); + btn_hora.add_css_class("context-menu-item"); + btn_stream.add_css_class("context-menu-item"); + btn_load_list.add_css_class("context-menu-item"); + btn_regen.add_css_class("context-menu-item"); + btn_del.add_css_class("context-menu-item"); + btn_del.add_css_class("context-menu-item-danger"); + + vbox.append(&btn_cue); + vbox.append(&btn_hora); + vbox.append(&btn_stream); + vbox.append(&btn_load_list); + vbox.append(&btn_regen); + vbox.append(&btn_del); + popover.set_child(Some(&vbox)); + + // Acción CUE: preescuchar por tarjeta secundaria + let pop_cue = popover.clone(); + let s_cue = s.clone(); + let win_cue = win_c.clone(); + let track_path_cue = PathBuf::from(path_str.clone()); + let title_cue = title.clone(); + btn_cue.connect_clicked(move |_| { + pop_cue.popdown(); + let uri = { + let p = track_path_cue.to_string_lossy(); + if p.starts_with("http://") || p.starts_with("https://") { + p.into_owned() + } else { + format!("file://{}", p) + } + }; + if uri == "file://" || uri.is_empty() { return; } + + // Detener CUE anterior si hay uno + let old_cue = s_cue.lock().unwrap().pipeline_cue.take(); + if let Some(p) = old_cue { shutdown_pipeline(p); } + + // Lanzar nuevo CUE + match build_pipeline_cue(&uri) { + Ok(pipeline) => { + let _ = pipeline.set_state(gst::State::Playing); + s_cue.lock().unwrap().pipeline_cue = Some(pipeline.clone()); + info!("CUE: reproduciendo {}", uri); + + // ── Ventana flotante de control CUE ── + let cue_win = gtk4::Window::new(); + cue_win.set_title(Some(&i18n::tr("cue.win_title").replace("{title}", &title_cue))); + cue_win.set_transient_for(Some(win_cue.upcast_ref::())); + cue_win.set_default_size(340, 90); + cue_win.set_resizable(false); + cue_win.set_decorated(true); + + let vbox = GtkBox::new(Orientation::Vertical, 8); + vbox.set_margin_top(12); + vbox.set_margin_bottom(12); + vbox.set_margin_start(12); + vbox.set_margin_end(12); + + // Barra de progreso + tiempo + let seek = gtk4::Scale::new( + Orientation::Horizontal, + Some(>k4::Adjustment::new(0.0, 0.0, 100.0, 1.0, 5.0, 0.0)) + ); + seek.set_hexpand(true); + seek.set_draw_value(false); + + let time_lbl = Label::new(Some("00:00 / 00:00")); + time_lbl.add_css_class("time-label"); + + let time_row = GtkBox::new(Orientation::Horizontal, 8); + time_row.append(&seek); + time_row.append(&time_lbl); + + // Botones + let btn_row = GtkBox::new(Orientation::Horizontal, 8); + btn_row.set_halign(gtk4::Align::Center); + let btn_pause_cue = Button::with_label(i18n::tr("cue.btn_pause")); + let btn_stop_cue = Button::with_label(i18n::tr("cue.btn_stop")); + btn_pause_cue.add_css_class("control-btn"); + btn_stop_cue.add_css_class("control-btn"); + btn_row.append(&btn_pause_cue); + btn_row.append(&btn_stop_cue); + + vbox.append(&time_row); + vbox.append(&btn_row); + cue_win.set_child(Some(&vbox)); + + // Pausa / Resume + let pipe_pause = pipeline.clone(); + btn_pause_cue.connect_clicked(move |btn| { + match pipe_pause.current_state() { + gst::State::Playing => { + let _ = pipe_pause.set_state(gst::State::Paused); + btn.set_label(i18n::tr("cue.btn_resume")); + } + _ => { + let _ = pipe_pause.set_state(gst::State::Playing); + btn.set_label(i18n::tr("cue.btn_pause")); + } + } + }); + + // Stop + let pipe_stop = pipeline.clone(); + let s_stop = s_cue.clone(); + let win_stop = cue_win.clone(); + btn_stop_cue.connect_clicked(move |_| { + let taken = s_stop.lock().unwrap().pipeline_cue.take(); + if let Some(p) = taken { shutdown_pipeline(p); } + else { shutdown_pipeline(pipe_stop.clone()); } + win_stop.close(); + }); + + // Cerrar ventana = detener CUE + let pipe_close = pipeline.clone(); + let s_close = s_cue.clone(); + cue_win.connect_close_request(move |_| { + let taken = s_close.lock().unwrap().pipeline_cue.take(); + if let Some(p) = taken { shutdown_pipeline(p); } + else { shutdown_pipeline(pipe_close.clone()); } + glib::Propagation::Proceed + }); + + // Timer: actualizar seek + tiempo + cerrar al EOS + let pipe_timer = pipeline.clone(); + let seek_c = seek.clone(); + let time_c = time_lbl.clone(); + let win_timer = cue_win.clone(); + let s_timer = s_cue.clone(); + timeout_add_local(Duration::from_millis(300), move || { + let pos = pipeline_position(&pipe_timer); + let dur = pipeline_duration(&pipe_timer).max(1.0); + seek_c.set_range(0.0, dur); + seek_c.set_value(pos); + time_c.set_text(&format!("{} / {}", + secs_to_display(pos), secs_to_display(dur))); + // Detectar EOS + if let Some(bus) = pipe_timer.bus() { + while let Some(msg) = bus.pop() { + if let gst::MessageView::Eos(_) = msg.view() { + let taken = s_timer.lock().unwrap().pipeline_cue.take(); + if let Some(p) = taken { shutdown_pipeline(p); } + win_timer.close(); + return glib::ControlFlow::Break; + } + } + } + // Detener timer si la ventana ya se cerró + if !win_timer.is_visible() { + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); + + // Seek manual + let pipe_seek = pipeline.clone(); + seek.connect_change_value(move |_, _, value| { + pipeline_seek(&pipe_seek, value); + glib::Propagation::Proceed + }); + + cue_win.present(); + } + Err(e) => error!("CUE: error al crear pipeline: {}", e), + } + }); + + // Acción Hora + let pop1 = popover.clone(); + let s_h = s_hora.clone(); + let pp_h = pp_hora.clone(); + btn_hora.connect_clicked(move |_| { + pop1.popdown(); + let hora_track = Track { + path: PathBuf::from("Hora"), + duration_secs: 0.0, + title: "🕐 Hora".to_string(), + }; + let mut st = s_h.lock().unwrap(); + st.playlist.insert(idx, hora_track); + save_playlist_to_file(&st.playlist, &pp_h); + st.playlist_version = st.playlist_version.wrapping_add(1); + }); + + // Acción Streaming + let pop2 = popover.clone(); + let s_s = s_stream.clone(); + let pp_s = pp_stream.clone(); + let win_r2 = win_ref.clone(); + btn_stream.connect_clicked(move |_| { + pop2.popdown(); + show_insert_stream_dialog(&win_r2, s_s.clone(), idx, pp_s.clone()); + }); + + // Acción Cargar lista .gradio en posición idx + { + let pop_ll = popover.clone(); + let s_ll = s.clone(); + let win_ll = win_c.clone(); + btn_load_list.connect_clicked(move |_| { + pop_ll.popdown(); + let fc = gtk4::FileChooserDialog::new( + Some("Cargar lista .gradio"), + Some(win_ll.upcast_ref::()), + gtk4::FileChooserAction::Open, + &[("Cancelar", gtk4::ResponseType::Cancel), + ("Insertar", gtk4::ResponseType::Accept)], + ); + fc.set_modal(true); + let filter = gtk4::FileFilter::new(); + filter.set_name(Some("Listas G Radio (*.gradio)")); + filter.add_pattern("*.gradio"); + fc.add_filter(&filter); + let s_fc = s_ll.clone(); + fc.connect_response(move |fc, resp| { + if resp == gtk4::ResponseType::Accept { + if let Some(file) = fc.file() { + if let Some(path) = file.path() { + apply_gradio_file(&s_fc, &path, Some(idx)); + } + } + } + fc.close(); + }); + fc.present(); + }); + } + + // Acción Regenerar lista (vaciar y dejar que gr-parrilla la rellene) + { + let pop_rg = popover.clone(); + let s_rg = s.clone(); + let pp_rg = pp_hora.clone(); + btn_regen.connect_clicked(move |_| { + pop_rg.popdown(); + let mut st = s_rg.lock().unwrap(); + st.playlist.clear(); + save_playlist_to_file(&st.playlist, &pp_rg); + st.playlist_version = st.playlist_version.wrapping_add(1); + info!("Playlist vaciada para regeneración por gr-parrilla"); + }); + } + + // Acción Eliminar + let pop3 = popover.clone(); + let s_d = s.clone(); + let pp_d = pp_hora.clone(); // misma ruta playlist4 + btn_del.connect_clicked(move |_| { + pop3.popdown(); + let mut st = s_d.lock().unwrap(); + if idx < st.playlist.len() { + st.playlist.remove(idx); + save_playlist_to_file(&st.playlist, &pp_d); + st.playlist_version = st.playlist_version.wrapping_add(1); + } + }); + + // Botón derecho: posicionar popover en el cursor y abrir + let pop_ref = popover.clone(); + let right_click = GestureClick::new(); + right_click.set_button(3); + right_click.connect_released(move |gesture, _n, x, y| { + gesture.set_state(gtk4::EventSequenceState::Claimed); + pop_ref.set_pointing_to(Some(&gdk4::Rectangle::new( + x as i32, y as i32, 1, 1, + ))); + pop_ref.popup(); + }); + row_box.add_controller(right_click); + } + + playlist_listbox_c.append(&row); + } + } + + // ── Helper: actualizar un listbox de cola desde un archivo ── + // Reconstruye si cambió la "firma" del archivo (líneas concatenadas). + // Cada fila ahora muestra título + duración + botones up/down. + let mut update_queue = |listbox: &ListBox, + file_path: &std::path::Path, + last_sig: &mut String| { + let lines: Vec = fs::read_to_string(file_path) + .unwrap_or_default() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(String::from) + .collect(); + + let signature: String = lines.join("\n"); + if signature == *last_sig { return; } + *last_sig = signature; + + while let Some(child) = listbox.first_child() { + listbox.remove(&child); + } + let n = lines.len(); + for (i, line) in lines.iter().enumerate() { + let row_box = GtkBox::new(Orientation::Horizontal, 4); + + let title_text = display_name_for_queue_line(line); + let title_lbl = Label::new(Some(&title_text)); + title_lbl.set_halign(gtk4::Align::Start); + title_lbl.set_hexpand(true); + title_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + title_lbl.set_tooltip_text(Some(line)); + + let dur = duration_for_queue_line(line); + let dur_str = if dur > 0.0 { secs_to_display(dur) } else { String::from("—") }; + let dur_lbl = Label::new(Some(&dur_str)); + dur_lbl.add_css_class("queue-dur-label"); + dur_lbl.set_halign(gtk4::Align::End); + + let btn_up = icon_button(&icon_queue_up(), "Subir en la cola", 24); + let btn_down = icon_button(&icon_queue_down(), "Bajar en la cola", 24); + btn_up.add_css_class("queue-action-btn"); + btn_down.add_css_class("queue-action-btn"); + btn_up.set_sensitive(i > 0); + btn_down.set_sensitive(i + 1 < n); + { + let fp = file_path.to_path_buf(); + btn_up.connect_clicked(move |_| { + if i > 0 { swap_lines_in_file(&fp, i, i - 1); } + }); + } + { + let fp = file_path.to_path_buf(); + btn_down.connect_clicked(move |_| { + if i + 1 < n { swap_lines_in_file(&fp, i, i + 1); } + }); + } + + row_box.append(&title_lbl); + row_box.append(&dur_lbl); + row_box.append(&btn_up); + row_box.append(&btn_down); + + let row = ListBoxRow::new(); + row.set_child(Some(&row_box)); + if i == 0 { row.add_css_class("playing-row"); } + listbox.append(&row); + } + }; + + // ── Actualizar paneles de cola (espera y eventos) ── + update_queue(&espera_listbox_c, + &home_c.join(".gradio/data/tmp/eventos-esperalist"), + &mut last_signature_espera); + update_queue(&eventos_listbox_c, + &home_c.join(".gradio/data/tmp/eventoslist"), + &mut last_signature_evt); + + // ── Visibilidad dinámica del bloque de eventos ───────────────────────── + { + let hay_espera = !fs::read_to_string(&home_c.join(".gradio/data/tmp/eventos-esperalist")) + .unwrap_or_default().trim().is_empty(); + let hay_eventos = !fs::read_to_string(&home_c.join(".gradio/data/tmp/eventoslist")) + .unwrap_or_default().trim().is_empty(); + let mostrar = hay_espera || hay_eventos; + if paned_eventos_c.is_visible() != mostrar { + paned_eventos_c.set_visible(mostrar); + } + } + + // ── Actualizar próximas tandas de comerciales ────────────────────────── + // Watcher de comercialeslist4: refresca la cola activa en < 200ms + { + let sig = fs::read_to_string(&home_c.join(".gradio/data/tmp/comercialeslist4")) + .unwrap_or_default() + .lines() + .take(30) + .collect::>() + .join("\n"); + if sig != last_sig_comlist { + last_sig_comlist = sig; + rebuild_tandas_box(&tandas_box_c, &home_c, s.clone()); + upcoming_refresh_counter = 0; // evitar doble rebuild + } + } + + upcoming_refresh_counter += 1; + if upcoming_refresh_counter >= 150 { // cada 30 segundos (150 × 200ms) + upcoming_refresh_counter = 0; + rebuild_tandas_box(&tandas_box_c, &home_c, s.clone()); + } + + glib::ControlFlow::Continue + }); + + window.set_child(Some(&root)); + + // Ícono de G Radio en el taskbar/titlebar — usar connect_realize igual que el resto de apps + { + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + let _ = loader.write(&icon_gradio()); + 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); + let window2 = window.clone(); + window.connect_realize(move |w| { + if let Some(surf) = w.surface() { + use gdk4::prelude::ToplevelExt; + if let Some(tl) = surf.dynamic_cast_ref::() { + tl.set_icon_list(&[tex.clone()]); + } + let display = gtk4::prelude::WidgetExt::display(w); + use gdk4::prelude::DisplayExt; + if let Some(monitor) = display.monitor_at_surface(&surf) { + use gdk4::prelude::MonitorExt; + let geo = monitor.geometry(); + let ancho = (geo.width() - 20).min(1100).max(700); + let alto = (geo.height() - 90).min(680).max(400); + window2.set_default_size(ancho, alto); + } + } + }); + } + } + + // ── Limpieza al cerrar la ventana ── + // Sin esto los pipelines en PLAYING se descartan sin pasar por NULL → CRITICAL de GStreamer + { + let state_close = state.clone(); + window.connect_close_request(move |_| { + let mut st = state_close.lock().unwrap(); + if let Some(p) = st.pipeline_a.take() { shutdown_pipeline(p); } + if let Some(p) = st.pipeline_b.take() { shutdown_pipeline(p); } + if let Some(p) = st.pipeline_comm.take() { shutdown_pipeline(p); } + if let Some(p) = st.pipeline_cue.take() { shutdown_pipeline(p); } + if let Some(p) = st.pipeline_botonera.take() { shutdown_pipeline(p); } + glib::Propagation::Proceed + }); + } + + window.present(); + + // ── Autoplay: iniciar reproducción al arrancar ── + { + let state_ap = state.clone(); + let started_at = std::time::Instant::now(); + // En Windows el registro de plugins GST se construye en el primer + // arranque (portable) y puede tardar ~2s antes de crear el primer + // pipeline; en Linux el registro del sistema ya está listo, sin margen. + #[cfg(target_os = "windows")] + let min_wait = Duration::from_secs(2); + #[cfg(not(target_os = "windows"))] + let min_wait = Duration::ZERO; + + // playlist-refill (proceso aparte) escribe playlist4 y el timer de + // sincronización de la UI tarda hasta 200ms en recargarlo a + // st.playlist. Antes se comprobaba una sola vez: si la cola seguía + // vacía justo en ese instante el autoplay se perdía para siempre + // aunque la parrilla existiera. Ahora se reintenta cada 200ms + // hasta agotar el margen, en vez de comprobar una única vez. + const AUTOPLAY_TIMEOUT: Duration = Duration::from_secs(20); + timeout_add_local(Duration::from_millis(200), move || { + if started_at.elapsed() < min_wait { + return glib::ControlFlow::Continue; + } + let has_tracks = state_ap.lock().unwrap().playlist.len() > 0; + if has_tracks { + start_next_track(state_ap.clone(), 0); + return glib::ControlFlow::Break; + } + if started_at.elapsed() >= min_wait + AUTOPLAY_TIMEOUT { + warn!("Autoplay: sin tracks en playlist tras {}s, se omite arranque automático", + AUTOPLAY_TIMEOUT.as_secs()); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); + } +} + +// ── Menú contextual para vaciar cola ───────────────────────────────────────── + +fn agregar_menu_vaciar( + frame: >k4::Frame, + nombre: &'static str, + archivo: std::path::PathBuf, + state: SharedState, +) { + use gtk4::prelude::*; + + let gesture = gtk4::GestureClick::new(); + gesture.set_button(3); // botón derecho + + let frame_weak = frame.downgrade(); + gesture.connect_released(move |_g, _n, x, y| { + let Some(frame) = frame_weak.upgrade() else { return }; + + let popover = gtk4::Popover::new(); + popover.set_parent(&frame); + popover.set_position(gtk4::PositionType::Bottom); + let gdk_rect = gtk4::gdk::Rectangle::new(x as i32, y as i32, 1, 1); + popover.set_pointing_to(Some(&gdk_rect)); + + let vbox = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + vbox.set_margin_top(4); + vbox.set_margin_bottom(4); + vbox.set_margin_start(4); + vbox.set_margin_end(4); + + let lbl_titulo = gtk4::Label::new(Some(&format!("Cola: {}", nombre))); + lbl_titulo.add_css_class("dim-label"); + lbl_titulo.set_margin_bottom(4); + vbox.append(&lbl_titulo); + + let btn_vaciar = gtk4::Button::with_label(&format!("🗑 Vaciar {}", nombre)); + btn_vaciar.add_css_class("destructive-action"); + + let archivo_v = archivo.clone(); + let state_v = state.clone(); + let popover_weak = popover.downgrade(); + btn_vaciar.connect_clicked(move |_| { + let _ = std::fs::write(&archivo_v, ""); + interrupt_pautaje(&state_v); + info!("Vaciada cola: {}", archivo_v.display()); + if let Some(p) = popover_weak.upgrade() { p.popdown(); } + }); + vbox.append(&btn_vaciar); + + popover.set_child(Some(&vbox)); + popover.popup(); + }); + + frame.add_controller(gesture); +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..5ca4038 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,171 @@ +// models.rs — Estructuras de datos para GR Pautaje + +use chrono::NaiveDate; +use std::fmt; + +// ─── Tipo de pautaje ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +pub enum TipoPautaje { + Comerciales, + Eventos, + EventosEnEspera, +} + +impl TipoPautaje { + /// Nombre de la subcarpeta en ~/.gradio/data/ + pub fn carpeta(&self) -> &str { + match self { + TipoPautaje::Comerciales => "comerciales", + TipoPautaje::Eventos => "eventos", + TipoPautaje::EventosEnEspera => "eventos-espera", + } + } + + pub fn label(&self) -> &str { + match self { + TipoPautaje::Comerciales => "Comerciales", + TipoPautaje::Eventos => "Eventos", + TipoPautaje::EventosEnEspera => "Eventos en Espera", + } + } + + pub fn from_index(i: u32) -> Self { + match i { + 0 => TipoPautaje::Comerciales, + 1 => TipoPautaje::Eventos, + 2 => TipoPautaje::EventosEnEspera, + _ => TipoPautaje::Comerciales, + } + } + + pub fn to_index(&self) -> u32 { + match self { + TipoPautaje::Comerciales => 0, + TipoPautaje::Eventos => 1, + TipoPautaje::EventosEnEspera => 2, + } + } +} + +impl fmt::Display for TipoPautaje { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.label()) + } +} + +// ─── Entrada de pautaje ──────────────────────────────────────────────────── + +/// Una línea del archivo .com: +/// /ruta/al/audio.mp3|1234567|20260307|20260406 +/// +/// `dias` = cadena de dígitos 1-7 donde cada dígito presente indica +/// que el audio se emite ese día de la semana (1=Lun … 7=Dom). +#[derive(Debug, Clone)] +pub struct EntradaPautaje { + pub ruta: String, + pub dias: String, + pub inicio: NaiveDate, + pub fin: NaiveDate, +} + +impl EntradaPautaje { + /// Nombre del archivo (con extensión) + pub fn nombre(&self) -> String { + std::path::Path::new(&self.ruta) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&self.ruta) + .to_string() + } + + /// Nombre sin extensión para mostrar en la tabla + pub fn nombre_display(&self) -> String { + let ruta = self.ruta.trim_end_matches('\r'); + // Entrada de carpeta aleatoria: "/ruta/carpeta/*" + if ruta.ends_with("/*") || (ruta.ends_with('*') && ruta.contains('/')) { + let folder = ruta.trim_end_matches('*').trim_end_matches('/'); + let folder_name = std::path::Path::new(folder) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(folder); + return format!("📁 {} (aleatoria)", folder_name); + } + let n = self.nombre(); + match n.rfind('.') { + Some(pos) => n[..pos].to_string(), + None => n, + } + } + + /// Serializa al formato del archivo .com (con salto de línea) + pub fn serializar(&self) -> String { + format!( + "{}|{}|{}|{}\n", + self.ruta, + self.dias, + self.inicio.format("%Y%m%d"), + self.fin.format("%Y%m%d"), + ) + } + + /// Parsea una línea del archivo .com + pub fn parsear(linea: &str) -> Option { + let partes: Vec<&str> = linea.trim().splitn(4, '|').collect(); + if partes.len() < 4 { return None; } + let ruta = partes[0].to_string(); + let dias = partes[1].to_string(); + let inicio = NaiveDate::parse_from_str(partes[2], "%Y%m%d").ok()?; + let fin = NaiveDate::parse_from_str(partes[3], "%Y%m%d").ok()?; + Some(EntradaPautaje { ruta, dias, inicio, fin }) + } + + /// Duración real del archivo de audio en segundos. + /// Lee los metadatos del archivo sin dependencias externas. + pub fn duracion_secs(&self) -> u64 { + crate::duracion_audio::leer_duracion(&self.ruta) + } + + /// Duración formateada como "M:SS" o "H:MM:SS" + pub fn duracion_fmt(&self) -> String { + crate::duracion_audio::formato_duracion(self.duracion_secs()) + } +} + +// ─── Estado de la aplicación ─────────────────────────────────────────────── + +/// Estado compartido entre todos los módulos de UI (via Rc>) +#[derive(Debug)] +pub struct Estado { + pub fecha_inicio: NaiveDate, + pub fecha_fin: NaiveDate, + /// Días activos: cadena de dígitos '1'..'7' ordenados (ej: "12345") + pub dias: String, + pub tipo: TipoPautaje, + pub hora: u8, + pub minuto: u8, +} + +impl Default for Estado { + fn default() -> Self { + let hoy = chrono::Local::now().date_naive(); + Estado { + fecha_inicio: hoy, + fecha_fin: hoy, + dias: "1234567".to_string(), + tipo: TipoPautaje::Comerciales, + hora: 0, + minuto: 0, + } + } +} + +// ─── Utilidades ──────────────────────────────────────────────────────────── + +/// Formatea segundos como "HH:MM:SS" +pub fn formato_hhmmss(segundos: u64) -> String { + let h = segundos / 3600; + let m = (segundos % 3600) / 60; + let s = segundos % 60; + format!("{:02}:{:02}:{:02}", h, m, s) +} diff --git a/src/playlist_refill.rs b/src/playlist_refill.rs new file mode 100644 index 0000000..b86c7bc --- /dev/null +++ b/src/playlist_refill.rs @@ -0,0 +1,508 @@ +use rand::seq::SliceRandom; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, SystemTime}; +use chrono::{Datelike, Timelike, Local}; + +const TARGET_LEN: usize = 14; + +// Cache de validación (path + mtime → playable). Persiste durante la vida +// del daemon para no re-decodificar el mismo archivo en cada ronda de refill. +static VALIDATION_CACHE: OnceLock>> = OnceLock::new(); +fn validation_cache() -> &'static Mutex> { + VALIDATION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Wrapper cacheado sobre `audio_probe::is_playable`. Decodifica el archivo +/// la primera vez; las siguientes consultas devuelven el resultado cacheado +/// (invalidado si cambió el mtime). Imprime warning a stderr si el archivo +/// resulta inválido. +fn is_playable_cached(path: &Path) -> bool { + let mtime = match fs::metadata(path).and_then(|m| m.modified()) { + Ok(t) => t, + Err(_) => return false, + }; + let key = (path.to_path_buf(), mtime); + { + let cache = validation_cache().lock().unwrap(); + if let Some(&v) = cache.get(&key) { return v; } + } + let ok = grpautaje::audio_probe::is_playable(path); + if !ok { + eprintln!("[refill] audio inválido, descartado: {}", path.display()); + } + validation_cache().lock().unwrap().insert(key, ok); + ok +} + +fn main() { + let mut last_mus_hash: Option = None; + // Índice persistente: indica la SIGUIENTE entrada de la parrilla a usar. + // Se reinicia a 0 cuando cambia la hora (nuevo .mus). + let mut parrilla_idx: usize = 0; + + loop { + // Respetar pausa manual del operador (archivo creado por el botón Automático en la UI) + let pause_file = dirs::home_dir().unwrap().join(".gradio/data/tmp/pause_refill"); + if pause_file.exists() { + thread::sleep(Duration::from_secs(5)); + continue; + } + + let current_mus = parrilla_path(); + let current_hash = mus_fingerprint(¤t_mus); + + match (&last_mus_hash, ¤t_hash) { + (Some(prev), Some(curr)) if prev != curr => { + eprintln!("[refill] Cambio de parrilla detectado, vaciando playlist4."); + vaciar_playlist(); + parrilla_idx = 0; // reiniciar índice al cambiar de hora + last_mus_hash = current_hash; + } + (None, _) => { + last_mus_hash = current_hash; + } + _ => {} + } + + parrilla_idx = refill(parrilla_idx); + thread::sleep(Duration::from_secs(5)); + } +} + +fn vaciar_playlist() { + let path = playlist_path(); + if let Err(e) = fs::write(&path, "") { + eprintln!("[refill] Error vaciando playlist4: {}", e); + } +} + +fn mus_fingerprint(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + let len = content.len(); + let head: String = content.chars().take(200).collect(); + let tail: String = content.chars().rev().take(200).collect(); + Some(format!("{}|{}|{}", len, head, tail)) +} + +/// Rellena la playlist respetando estrictamente el orden de la parrilla. +/// Recibe el índice actual de parrilla y devuelve el nuevo índice tras el relleno. +fn refill(mut parrilla_idx: usize) -> usize { + let playlist = playlist_path(); + let lines = current_lines(&playlist); + if lines.len() >= TARGET_LEN { return parrilla_idx; } + + // Carpetas excluidas del control de repetición (leídas de gradio.config) + let no_repeat_dirs = read_no_repeat_dirs(); + + // Set de exclusión: log de 3 días + lo que ya está en la playlist actual + // EXCEPTO para archivos en carpetas no_repeat_dirs + let recent = played_recent_set(read_no_repeat_days()); + let mut excluded = HashSet::new(); + // Agregar lo que ya está en la playlist (siempre, para evitar duplicados inmediatos) + for line in &lines { + let path = line.splitn(2, '\t').next().unwrap_or("").trim(); + if !path.is_empty() { + excluded.insert(path.to_string()); + } + } + + let needed = TARGET_LEN - lines.len(); + let parrilla = load_parrilla(); + if parrilla.is_empty() { return parrilla_idx; } + + let parrilla_len = parrilla.len(); + // Asegurar que el índice no supere la longitud de la parrilla + parrilla_idx = parrilla_idx % parrilla_len; + + let mut added = 0; + // Protección contra bucle infinito: máximo una vuelta completa sin agregar nada + let mut consecutive_empty = 0; + + while added < needed { + let raw = parrilla[parrilla_idx].trim(); + + // Marcador especial "Hora" — se inserta directamente sin filtro de exclusión + if raw == "Hora" { + append_line(&playlist, "Hora", 0.0); + added += 1; + consecutive_empty = 0; + parrilla_idx = (parrilla_idx + 1) % parrilla_len; + continue; + } + + // Construir el excluded para esta entrada específica: + // Si la carpeta está en no_repeat_dirs, no aplicar log de 3 días + let entry_excluded = build_excluded_for_entry(raw, &recent, &excluded, &no_repeat_dirs); + + match pick_from_entry(raw, &entry_excluded) { + Some(resolved) => { + let dur = duration(&resolved); + append_line(&playlist, &resolved, dur); + excluded.insert(resolved); // evitar duplicado en este ciclo + added += 1; + consecutive_empty = 0; + // Avanzar al siguiente índice de parrilla SOLO tras agregar exitosamente + parrilla_idx = (parrilla_idx + 1) % parrilla_len; + } + None => { + // Entrada agotada o no disponible: saltar y continuar + eprintln!("[refill] entrada agotada: {}", raw); + parrilla_idx = (parrilla_idx + 1) % parrilla_len; + consecutive_empty += 1; + // Si dimos una vuelta completa sin éxito, salir + if consecutive_empty >= parrilla_len { + eprintln!("[refill] todas las entradas agotadas; esperando próxima ronda."); + break; + } + } + } + } + + parrilla_idx +} + +/// Construye el set de exclusión para una entrada de parrilla concreta. +/// Si la entrada pertenece a una carpeta en no_repeat_dirs, solo excluye +/// lo que ya está en la playlist actual (no el log de 3 días). +fn build_excluded_for_entry( + raw: &str, + recent: &HashSet, + playlist_current: &HashSet, + no_repeat_dirs: &[String], +) -> HashSet { + let base = normalize_dir(raw); + let in_no_repeat = no_repeat_dirs.iter().any(|excl| { + let excl_norm = excl.trim_end_matches('/'); + base == excl_norm || base.starts_with(&format!("{}/", excl_norm)) + }); + + if in_no_repeat { + // Solo evitar duplicados inmediatos en la lista actual + playlist_current.clone() + } else { + // Exclusión completa: log 3 días + playlist actual + let mut full = recent.clone(); + full.extend(playlist_current.iter().cloned()); + full + } +} + +/// Normaliza una ruta de parrilla a su directorio base (sin /* ni /) +fn normalize_dir(raw: &str) -> String { + raw.trim() + .trim_end_matches('/') + .trim_end_matches('*') + .trim_end_matches('/') + .to_string() +} + +/// Lee las carpetas excluidas del control de repetición desde gradio.config +fn read_no_repeat_dirs() -> Vec { + let path = dirs::home_dir().unwrap().join(".gradio/data/tmp/gradio.config"); + let text = fs::read_to_string(&path).unwrap_or_default(); + let mut lines = text.lines(); + // Línea 1: tarjeta principal, 2: CUE, 3: nombre, 4: fundido, + // 5: pisador_enabled, 6: pisador_dir, 7: pisador_every, 8: excluidas + for _ in 0..7 { lines.next(); } + lines.next() + .map(|l| parse_quoted_semicolon_list(l.trim())) + .unwrap_or_default() +} + +/// Parsea lista de rutas entre comillas separadas por ; +fn parse_quoted_semicolon_list(s: &str) -> Vec { + let mut result = Vec::new(); + let mut rest = s; + while !rest.is_empty() { + rest = rest.trim_start(); + if rest.starts_with('"') { + rest = &rest[1..]; + if let Some(end) = rest.find('"') { + result.push(rest[..end].to_string()); + rest = &rest[end + 1..]; + rest = rest.trim_start_matches(';'); + } else { + result.push(rest.to_string()); + break; + } + } else if let Some(end) = rest.find(';') { + let item = rest[..end].trim().to_string(); + if !item.is_empty() { result.push(item); } + rest = &rest[end + 1..]; + } else { + let item = rest.trim().to_string(); + if !item.is_empty() { result.push(item); } + break; + } + } + result +} + +// --- Rutas ------------------------------------------------------------------- + +fn playlist_path() -> PathBuf { + dirs::home_dir().unwrap().join(".gradio/data/tmp/playlist4") +} + +fn parrilla_path() -> PathBuf { + let now = Local::now(); + let dow = now.weekday().number_from_monday(); + let hour = now.hour(); + let file = format!("{}-{}.mus", hour, hour + 1); + dirs::home_dir() + .unwrap() + .join(".gradio/data/parrilla") + .join(dow.to_string()) + .join(file) +} + +fn log_parrilla_path_for(date: chrono::NaiveDate) -> PathBuf { + dirs::home_dir() + .unwrap() + .join(".gradio/data/reporte") + .join(format!("GR6-parrilla-{}.txt", date.format("%Y-%m-%d"))) +} + +fn read_no_repeat_days() -> u32 { + let path = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/root")) + .join(".gradio/data/tmp/gradio.config"); + std::fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .nth(15) // línea 16, índice 0-based + .and_then(|l| l.trim().parse::().ok()) + .filter(|&v| v >= 1) + .unwrap_or(3) +} + +fn played_recent_set(days: u32) -> HashSet { + let today = Local::now().date_naive(); + let mut set = HashSet::new(); + for d in 0..days { + let date = today - chrono::Duration::days(d as i64); + let path = log_parrilla_path_for(date); + if !path.exists() { continue; } + if let Ok(f) = File::open(&path) { + for line in BufReader::new(f).lines().filter_map(|l| l.ok()) { + if let Some(ruta) = line.splitn(4, ',').nth(2) { + let ruta = ruta.trim().to_string(); + if !ruta.is_empty() { set.insert(ruta); } + } + } + } + } + set +} + +// --- Carga de parrilla ------------------------------------------------------- + +fn load_parrilla() -> Vec { + let path = parrilla_path(); + if !path.exists() { + eprintln!("[refill] Parrilla no encontrada: {:?}", path); + return vec![]; + } + match File::open(&path) { + Ok(f) => BufReader::new(f) + .lines() + .filter_map(|l| l.ok()) + .filter(|l| !l.trim().is_empty()) + .collect(), + Err(e) => { eprintln!("[refill] No se puede abrir parrilla: {}", e); vec![] } + } +} + +// --- Selección de entradas --------------------------------------------------- + +/// Intenta resolver una entrada de parrilla con exclusión dada. +/// Primero respeta la exclusión completa; si falla, relaja a solo la playlist actual. +fn pick_from_entry(raw: &str, excluded: &HashSet) -> Option { + if let Some(r) = resolve_entry(raw, excluded) { + return Some(r); + } + // Relajar: solo evitar duplicados inmediatos (excluded ya es el correcto según no_repeat) + // En este punto no podemos relajar más sin conocer si es no_repeat o no, + // así que simplemente intentamos con un set vacío como último recurso + resolve_entry_any(raw, &HashSet::new()) +} + +fn resolve_entry(raw: &str, excluded: &HashSet) -> Option { + let raw = raw.trim(); + // Ignorar cualquier entrada cuya ruta contenga una carpeta "No Tocar" + if is_no_tocar(Path::new(raw)) { return None; } + if !raw.ends_with("/*") && !Path::new(raw).is_dir() { + if excluded.contains(raw) { return None; } + let p = Path::new(raw); + if !p.exists() { + eprintln!("[refill] Archivo no encontrado: {}", raw); + return None; + } + if !is_playable_cached(p) { return None; } + return Some(raw.to_string()); + } + let base_dir = base_dir_of(raw); + if !base_dir.exists() { + eprintln!("[refill] Directorio no encontrado: {:?}", base_dir); + return None; + } + let subdirs = subdirs_of(&base_dir); + if subdirs.is_empty() { + pick_from_flat_dir(&base_dir, excluded) + } else { + pick_from_subdirs(&subdirs, excluded) + } +} + +fn resolve_entry_any(raw: &str, excluded: &HashSet) -> Option { + resolve_entry(raw, excluded) +} + +fn base_dir_of(raw: &str) -> PathBuf { + PathBuf::from(if raw.ends_with("/*") { + raw.trim_end_matches("/*") + } else { + raw + }) +} + +// --- Selección en carpetas --------------------------------------------------- + +fn pick_from_flat_dir(dir: &Path, excluded: &HashSet) -> Option { + let mut files = audio_files_in_dir(dir); + if files.is_empty() { + eprintln!("[refill] Carpeta vacía: {:?}", dir); + return None; + } + files.shuffle(&mut rand::thread_rng()); + for f in files { + let s = f.to_str().unwrap_or(""); + if excluded.contains(s) { continue; } + if !is_playable_cached(&f) { continue; } + return Some(f.to_string_lossy().into_owned()); + } + None +} + +/// Elige un tema al azar de entre TODOS los disponibles en todas las subcarpetas +/// combinadas en un pool plano. Esto evita que carpetas con pocos temas sean +/// elegidas con la misma frecuencia que carpetas grandes (sesgo de subdir). +fn pick_from_subdirs(subdirs: &[PathBuf], excluded: &HashSet) -> Option { + let mut pool: Vec = subdirs.iter() + .flat_map(|sd| audio_files_in_dir(sd)) + .filter(|f| !excluded.contains(f.to_str().unwrap_or(""))) + .collect(); + pool.shuffle(&mut rand::thread_rng()); + for f in pool { + if !is_playable_cached(&f) { continue; } + return Some(f.to_string_lossy().into_owned()); + } + None +} + +// --- Archivos de audio ------------------------------------------------------- + +fn audio_files_in_dir(dir: &Path) -> Vec { + match fs::read_dir(dir) { + Ok(rd) => rd + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file() && is_audio(p)) + .collect(), + Err(_) => vec![], + } +} + +/// Devuelve true si algún componente del path se llama "No Tocar" (sin distinguir +/// mayúsculas/minúsculas ASCII), para filtrar carpetas reservadas. +fn is_no_tocar(path: &Path) -> bool { + path.components().any(|c| { + c.as_os_str().to_string_lossy().eq_ignore_ascii_case("no tocar") + }) +} + +fn subdirs_of(dir: &Path) -> Vec { + match fs::read_dir(dir) { + Ok(rd) => rd + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir() && !is_no_tocar(p)) + .collect(), + Err(_) => vec![], + } +} + +fn is_audio(p: &Path) -> bool { + p.extension() + .and_then(|e| e.to_str()) + .map_or(false, |e| matches!( + e.to_ascii_lowercase().as_str(), + "mp3" | "m4a" | "wav" | "flac" | "ogg" + )) +} + +// --- Utilidades de archivo --------------------------------------------------- + +fn current_lines(p: &Path) -> Vec { + if !p.exists() { return vec![]; } + BufReader::new(match File::open(p) { + Ok(f) => f, + Err(_) => return vec![], + }) + .lines() + .filter_map(|l| l.ok()) + .filter(|l| !l.trim().is_empty()) + .collect() +} + +fn duration(path: &str) -> f64 { + let out = Command::new("ffprobe") + .args(&[ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + path, + ]) + .output(); + match out { + Ok(o) => String::from_utf8_lossy(&o.stdout) + .trim() + .parse::() + .unwrap_or(0.0), + Err(_) => 0.0, + } +} + +fn append_line(playlist: &Path, file: &str, secs: f64) { + let file = sanitize_path(file); + let mut fh = fs::OpenOptions::new() + .create(true) + .append(true) + .open(playlist) + .unwrap(); + writeln!( + fh, + "{}\t{:02}:{:02}.000", + file, + secs as u64 / 60, + secs as u64 % 60 + ) + .unwrap(); +} + +fn sanitize_path(raw: &str) -> String { + let raw = raw.trim().trim_end_matches('\r').trim_end_matches('/'); + for ext in &["mp3", "MP3", "wav", "WAV", "flac", "FLAC", "m4a", "M4A", "ogg", "OGG"] { + let needle = format!(".{}/", ext); + if let Some(p) = raw.find(&needle) { + return raw[..p + needle.len() - 1].to_string(); + } + } + raw.to_string() +} diff --git a/src/processor/biquad.rs b/src/processor/biquad.rs new file mode 100644 index 0000000..77694f2 --- /dev/null +++ b/src/processor/biquad.rs @@ -0,0 +1,79 @@ +/// Filtro biquad IIR de 2do orden, forma directa I. +/// Coeficientes normalizados (a0 = 1). +/// y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2] +#[derive(Clone, Debug)] +pub struct Biquad { + pub b0: f64, pub b1: f64, pub b2: f64, + pub a1: f64, pub a2: f64, + x1: f64, x2: f64, + y1: f64, y2: f64, +} + +impl Biquad { + pub fn identity() -> Self { + Self { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0, + x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 } + } + + /// Filtro pasa-bajos Butterworth 2do orden + /// Q = 1/sqrt(2) ≈ 0.7071 para respuesta Butterworth + pub fn lpf(fc: f32, sample_rate: f32) -> Self { + butterworth(fc, sample_rate, FilterType::Lpf) + } + + /// Filtro pasa-altos Butterworth 2do orden + pub fn hpf(fc: f32, sample_rate: f32) -> Self { + butterworth(fc, sample_rate, FilterType::Hpf) + } + + #[inline] + pub fn process(&mut self, x: f64) -> f64 { + let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2 + - self.a1 * self.y1 - self.a2 * self.y2; + self.x2 = self.x1; self.x1 = x; + self.y2 = self.y1; self.y1 = y; + y + } + + pub fn reset(&mut self) { + self.x1 = 0.0; self.x2 = 0.0; + self.y1 = 0.0; self.y2 = 0.0; + } +} + +#[derive(Clone, Copy)] +enum FilterType { Lpf, Hpf } + +fn butterworth(fc: f32, sample_rate: f32, t: FilterType) -> Biquad { + use std::f64::consts::PI; + let q = std::f64::consts::FRAC_1_SQRT_2; // 1/√2 = 0.7071 + let fc = fc as f64; + let fs = sample_rate as f64; + + let fc_clamped = fc.clamp(1.0, fs * 0.499); + let w0 = 2.0 * PI * fc_clamped / fs; + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / (2.0 * q); + + let (b0, b1, b2) = match t { + FilterType::Lpf => { + let b0 = (1.0 - cos_w0) / 2.0; + (b0, 1.0 - cos_w0, b0) + } + FilterType::Hpf => { + let b0 = (1.0 + cos_w0) / 2.0; + (b0, -(1.0 + cos_w0), b0) + } + }; + + let a0 = 1.0 + alpha; + let a1 = -2.0 * cos_w0; + let a2 = 1.0 - alpha; + + Biquad { + b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, + a1: a1 / a0, a2: a2 / a0, + x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0, + } +} diff --git a/src/processor/clipper.rs b/src/processor/clipper.rs new file mode 100644 index 0000000..0b54eb5 --- /dev/null +++ b/src/processor/clipper.rs @@ -0,0 +1,30 @@ +use super::BandConfig; + +/// Clipper con modo suave (tanh) y duro (hard clip). +pub struct Clipper; + +impl Clipper { + /// Clipper suave vía tanh. `level` es el nivel en dBFS al cual limitar. + #[inline] + pub fn soft(x: f32, level_db: f32) -> f32 { + let thr = 10f32.powf(level_db / 20.0); + let x_norm = x / thr; + // tanh normalizada para que el límite sea ±thr + x_norm.tanh() * thr + } + + #[inline] + pub fn hard(x: f32, level_db: f32) -> f32 { + let thr = 10f32.powf(level_db / 20.0); + x.clamp(-thr, thr) + } + + /// Aplica el clipper a un par de muestras según la config de banda. + #[inline] + pub fn process(l: &mut f32, r: &mut f32, cfg: &BandConfig) { + if !cfg.clip_enable { return; } + // Usamos clipper suave (tanh) – opcionalmente se podría alternar con hard clip + *l = Self::soft(*l, cfg.clip_level_db); + *r = Self::soft(*r, cfg.clip_level_db); + } +} diff --git a/src/processor/compressor.rs b/src/processor/compressor.rs new file mode 100644 index 0000000..929d9dc --- /dev/null +++ b/src/processor/compressor.rs @@ -0,0 +1,78 @@ +use super::BandConfig; + +/// Compresor linked-stereo feed-forward con detección RMS. +/// +/// "Linked stereo": el envelope se calcula con L²+R² (potencia total), +/// y ambos canales reciben la misma ganancia. Preserva la imagen estéreo. +/// +/// "Feed-forward": el detector mide la entrada, no la salida. +pub struct Compressor { + envelope: f32, // acumulador de potencia (dominio lineal cuadrado) + gain_db: f32, // ganancia actual (dB, negativo = reducción) + atk_coef: f32, // coef. filtro de 1er orden para ataque + rel_coef: f32, // coef. filtro de 1er orden para release +} + +impl Compressor { + pub fn new(cfg: &BandConfig, sample_rate: f32) -> Self { + // gain_db arranca en makeup_db (no en 0) para evitar el pop inicial: + // si empezara en 0 el audio saldría sin makeup hasta que el compresor converge. + let mut c = Self { envelope: 0.0, gain_db: cfg.makeup_db, atk_coef: 0.0, rel_coef: 0.0 }; + c.update_params(cfg, sample_rate); + c + } + + pub fn update_params(&mut self, cfg: &BandConfig, sample_rate: f32) { + self.atk_coef = Self::time_coef(cfg.attack_ms, sample_rate); + self.rel_coef = Self::time_coef(cfg.release_ms, sample_rate); + } + + fn time_coef(time_ms: f32, sample_rate: f32) -> f32 { + (-1.0 / (time_ms * 0.001 * sample_rate)).exp() + } + + /// Procesa una muestra estéreo (linked). Devuelve la reducción de + /// ganancia en dB (valor negativo o cero). + #[inline] + pub fn process_sample( + &mut self, + l: &mut f32, + r: &mut f32, + cfg: &BandConfig, + ) -> f32 { + if !cfg.enabled { return 0.0; } + + // Detección de potencia (linked stereo) + let power = *l * *l + *r * *r; + + // Filtro de sobre/bajada con tiempos diferenciados + let coef = if power > self.envelope { self.atk_coef } else { self.rel_coef }; + self.envelope = coef * self.envelope + (1.0 - coef) * power; + + // Nivel RMS en dBFS + let rms_db = 10.0 * (self.envelope + 1e-12).log10(); + + // Curva de compresión (sin knee por ahora) + let gr_db = if rms_db > cfg.threshold_db { + (cfg.threshold_db - rms_db) * (1.0 - 1.0 / cfg.ratio) + } else { + 0.0_f32 + }; + + // Suavizado de la ganancia con los mismos coeficientes + let gain_target = gr_db + cfg.makeup_db; + let g_coef = if gain_target < self.gain_db { self.atk_coef } else { self.rel_coef }; + self.gain_db = g_coef * self.gain_db + (1.0 - g_coef) * gain_target; + + let gain_lin = 10f32.powf(self.gain_db / 20.0); + *l *= gain_lin; + *r *= gain_lin; + + gr_db // retornamos solo la reducción (sin makeup) para el meter + } + + pub fn reset(&mut self) { + self.envelope = 0.0; + self.gain_db = 0.0; + } +} diff --git a/src/processor/crossover.rs b/src/processor/crossover.rs new file mode 100644 index 0000000..aed95d3 --- /dev/null +++ b/src/processor/crossover.rs @@ -0,0 +1,105 @@ +use super::biquad::Biquad; + +/// Filtro Linkwitz-Riley de 4° orden (LR4) para una frecuencia de corte. +/// Cascada de dos Butterworth de 2° orden idénticos. +/// La suma LP + HP es plana en magnitud y fase. +#[derive(Clone, Debug)] +pub struct Lr4Stage { + lp1_l: Biquad, lp2_l: Biquad, + hp1_l: Biquad, hp2_l: Biquad, + lp1_r: Biquad, lp2_r: Biquad, + hp1_r: Biquad, hp2_r: Biquad, +} + +impl Lr4Stage { + pub fn new(fc: f32, sample_rate: f32) -> Self { + Self { + lp1_l: Biquad::lpf(fc, sample_rate), + lp2_l: Biquad::lpf(fc, sample_rate), + hp1_l: Biquad::hpf(fc, sample_rate), + hp2_l: Biquad::hpf(fc, sample_rate), + lp1_r: Biquad::lpf(fc, sample_rate), + lp2_r: Biquad::lpf(fc, sample_rate), + hp1_r: Biquad::hpf(fc, sample_rate), + hp2_r: Biquad::hpf(fc, sample_rate), + } + } + + /// Divide una muestra en baja (lo) y alta (hi) por canal. + #[inline] + pub fn split_l(&mut self, x: f32) -> (f32, f32) { + let lo = self.lp2_l.process(self.lp1_l.process(x as f64)) as f32; + let hi = self.hp2_l.process(self.hp1_l.process(x as f64)) as f32; + (lo, hi) + } + + #[inline] + pub fn split_r(&mut self, x: f32) -> (f32, f32) { + let lo = self.lp2_r.process(self.lp1_r.process(x as f64)) as f32; + let hi = self.hp2_r.process(self.hp1_r.process(x as f64)) as f32; + (lo, hi) + } + + pub fn recalculate(&mut self, fc: f32, sample_rate: f32) { + *self = Self::new(fc, sample_rate); + } +} + +/// Red de crossover multiband con hasta 6 bandas (5 frecuencias de corte). +/// La cadena encadena N-1 etapas LR4: +/// +/// input → [Stage 0, f0] → lo → banda[0] +/// → hi → [Stage 1, f1] → lo → banda[1] +/// → hi → ... → banda[N-1] +pub struct Crossover { + stages: Vec, + pub num_bands: usize, + sample_rate: f32, +} + +impl Crossover { + pub fn new(freqs: &[f32; 5], num_bands: usize, sample_rate: f32) -> Self { + let n = num_bands.clamp(2, 6); + let stages = (0..n-1) + .map(|i| Lr4Stage::new(freqs[i], sample_rate)) + .collect(); + Self { stages, num_bands: n, sample_rate } + } + + pub fn recalculate(&mut self, freqs: &[f32; 5], num_bands: usize) { + let n = num_bands.clamp(2, 6); + self.num_bands = n; + while self.stages.len() < n - 1 { + self.stages.push(Lr4Stage::new(freqs[self.stages.len()], self.sample_rate)); + } + self.stages.truncate(n - 1); + for (i, stage) in self.stages.iter_mut().enumerate() { + stage.recalculate(freqs[i], self.sample_rate); + } + } + + /// Separa un par de muestras estéreo en `num_bands` pares de bandas. + /// `bands_l` y `bands_r` deben tener al menos `num_bands` elementos. + #[inline] + pub fn process_sample( + &mut self, + l: f32, r: f32, + bands_l: &mut [f32], + bands_r: &mut [f32], + ) { + let n = self.num_bands; + let mut rem_l = l; + let mut rem_r = r; + + for (i, stage) in self.stages.iter_mut().enumerate() { + let (lo_l, hi_l) = stage.split_l(rem_l); + let (lo_r, hi_r) = stage.split_r(rem_r); + bands_l[i] = lo_l; + bands_r[i] = lo_r; + rem_l = hi_l; + rem_r = hi_r; + } + bands_l[n - 1] = rem_l; + bands_r[n - 1] = rem_r; + } +} diff --git a/src/processor/limiter.rs b/src/processor/limiter.rs new file mode 100644 index 0000000..b20cec9 --- /dev/null +++ b/src/processor/limiter.rs @@ -0,0 +1,45 @@ +/// Limitador final brick-wall (peak limiter con release suavizado). +/// Sin look-ahead en v0.1: aplica gain instantáneo por muestra. +pub struct Limiter { + gain: f32, + rel_coef: f32, + thr_lin: f32, +} + +impl Limiter { + pub fn new(threshold_db: f32, sample_rate: f32) -> Self { + let release_ms = 200.0f32; + Self { + gain: 1.0, + rel_coef: (-1.0 / (release_ms * 0.001 * sample_rate)).exp(), + thr_lin: 10f32.powf(threshold_db / 20.0), + } + } + + pub fn update_threshold(&mut self, threshold_db: f32) { + self.thr_lin = 10f32.powf(threshold_db / 20.0); + } + + /// Procesa un par de muestras y retorna la reducción de ganancia (0..1). + #[inline] + pub fn process(&mut self, l: &mut f32, r: &mut f32) -> f32 { + let peak = l.abs().max(r.abs()); + let needed = if peak > self.thr_lin { + self.thr_lin / peak + } else { + 1.0 + }; + + // Ataque instantáneo, release suavizado + if needed < self.gain { + self.gain = needed; + } else { + self.gain = self.rel_coef * self.gain + (1.0 - self.rel_coef) * needed; + } + + *l *= self.gain; + *r *= self.gain; + + 1.0 - self.gain // 0 = sin limitación, 1 = máxima limitación + } +} diff --git a/src/processor/mod.rs b/src/processor/mod.rs new file mode 100644 index 0000000..891c7c4 --- /dev/null +++ b/src/processor/mod.rs @@ -0,0 +1,249 @@ +pub mod biquad; +pub mod crossover; +pub mod compressor; +pub mod clipper; +pub mod limiter; +pub mod ui; +pub mod presets; + +use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicU32, Ordering}; + +// ─── Metros globales de display ─────────────────────────────────────────────── +// Escritos por el pad probe del pipeline activo, leídos por la ventana de config. +// Independientes del Arc del DspProcessor: no requieren lock. +static DISP_LEVELS: [AtomicU32; 6] = [ + AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0), + AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0), +]; +static DISP_GRS: [AtomicU32; 6] = [ + AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0), + AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0), +]; + +pub fn display_level(b: usize) -> f32 { f32::from_bits(DISP_LEVELS[b].load(Ordering::Relaxed)) } +pub fn display_gr(b: usize) -> f32 { f32::from_bits(DISP_GRS[b].load(Ordering::Relaxed)) } +use serde::{Deserialize, Serialize}; +use crossover::Crossover; +use compressor::Compressor; +use clipper::Clipper; +use limiter::Limiter; + +// ─── AtomicF32 ──────────────────────────────────────────────────────────────── + +pub struct AtomicF32(AtomicU32); +impl AtomicF32 { + pub fn new(v: f32) -> Self { Self(AtomicU32::new(v.to_bits())) } + pub fn load(&self) -> f32 { f32::from_bits(self.0.load(Ordering::Relaxed)) } + pub fn store(&self, v: f32) { self.0.store(v.to_bits(), Ordering::Relaxed); } +} +impl Default for AtomicF32 { fn default() -> Self { Self::new(0.0) } } + +// ─── BandMeters ─────────────────────────────────────────────────────────────── + +pub struct BandMeters { + pub level: Arc, + pub gr: Arc, +} +impl BandMeters { + pub fn new() -> Self { + Self { + level: Arc::new(AtomicF32::new(0.0)), + gr: Arc::new(AtomicF32::new(0.0)), + } + } +} + +// ─── Configuración de banda ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BandConfig { + pub enabled: bool, + pub threshold_db: f32, + pub ratio: f32, + pub attack_ms: f32, + pub release_ms: f32, + pub makeup_db: f32, + pub clip_enable: bool, + pub clip_level_db: f32, + pub exp_enable: bool, + pub exp_threshold: f32, + pub exp_ratio: f32, +} + +impl Default for BandConfig { + fn default() -> Self { + Self { + enabled: true, + threshold_db: -18.0, + ratio: 3.0, + attack_ms: 10.0, + release_ms: 150.0, + makeup_db: 3.0, + clip_enable: true, + clip_level_db: -0.5, + exp_enable: false, + exp_threshold: -60.0, + exp_ratio: 2.0, + } + } +} + +// ─── Configuración del procesador ──────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessorConfig { + pub enabled: bool, + pub num_bands: usize, + pub crossover_freqs: [f32; 5], + pub input_gain_db: f32, + pub output_gain_db: f32, + pub limiter_threshold: f32, + pub bands: [BandConfig; 6], + pub sample_rate: u32, + #[serde(default)] + pub last_preset: String, +} + +impl Default for ProcessorConfig { + fn default() -> Self { + Self { + enabled: false, + num_bands: 5, + crossover_freqs: [120.0, 400.0, 2000.0, 6000.0, 12000.0], + input_gain_db: 0.0, + output_gain_db: 0.0, + limiter_threshold: -0.3, + bands: std::array::from_fn(|_| BandConfig::default()), + sample_rate: 48000, + last_preset: String::new(), + } + } +} + +impl ProcessorConfig { + fn config_path() -> std::path::PathBuf { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".gradio/data/processor.json") + } + + pub fn cargar() -> Self { + let path = Self::config_path(); + 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::config_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(&path, json); + } + } + + pub fn db_to_linear(db: f32) -> f32 { + 10f32.powf(db / 20.0) + } +} + +// ─── Procesador DSP (instancia por pipeline) ───────────────────────────────── + +pub struct DspProcessor { + pub cfg: ProcessorConfig, + pub meters: [BandMeters; 6], + crossover: Crossover, + compressors: [Compressor; 6], + limiter: Limiter, +} + +impl DspProcessor { + pub fn new(cfg: ProcessorConfig) -> Self { + let sr = cfg.sample_rate as f32; + let nb = cfg.num_bands.clamp(2, 6); + let crossover = Crossover::new(&cfg.crossover_freqs, nb, sr); + let compressors = std::array::from_fn(|i| Compressor::new(&cfg.bands[i], sr)); + let limiter = Limiter::new(cfg.limiter_threshold, sr); + let meters = std::array::from_fn(|_| BandMeters::new()); + Self { cfg, meters, crossover, compressors, limiter } + } + + /// Procesa un bloque de muestras F32LE intercaladas (L,R,L,R,...). + /// Modifica los samples in-place. + pub fn process_block(&mut self, samples: &mut [f32]) { + if !self.cfg.enabled { return; } + + let sr = self.cfg.sample_rate as f32; + let nb = self.cfg.num_bands.clamp(2, 6); + let in_g = ProcessorConfig::db_to_linear(self.cfg.input_gain_db); + let out_g = ProcessorConfig::db_to_linear(self.cfg.output_gain_db); + + let mut bl = [0.0f32; 6]; + let mut br = [0.0f32; 6]; + + let frames = samples.len() / 2; + let mut band_levels_sum = [0.0f32; 6]; + let mut band_gr_sum = [0.0f32; 6]; + + for f in 0..frames { + let l = samples[f * 2] * in_g; + let r = samples[f * 2 + 1] * in_g; + + self.crossover.process_sample(l, r, &mut bl, &mut br); + + let mut ol = 0.0f32; + let mut or_ = 0.0f32; + for b in 0..nb { + let bc = self.cfg.bands[b].clone(); + let gr = self.compressors[b].process_sample(&mut bl[b], &mut br[b], &bc); + Clipper::process(&mut bl[b], &mut br[b], &bc); + band_levels_sum[b] += (bl[b].abs() + br[b].abs()) * 0.5; + band_gr_sum[b] += gr.abs(); + ol += bl[b]; + or_ += br[b]; + } + self.limiter.process(&mut ol, &mut or_); + + samples[f * 2] = ol * out_g; + samples[f * 2 + 1] = or_ * out_g; + } + + // Actualizar medidores locales Y globales (display de la ventana de config) + let inv = if frames > 0 { 1.0 / frames as f32 } else { 1.0 }; + for b in 0..nb { + let lv = (band_levels_sum[b] * inv).clamp(0.0, 2.0); + let gr = (band_gr_sum[b] * inv / 40.0).clamp(0.0, 1.0); + self.meters[b].level.store(lv); + self.meters[b].gr .store(gr); + // Metros globales: leídos por la UI sin necesitar lock del DspProcessor + DISP_LEVELS[b].store(lv.to_bits(), Ordering::Relaxed); + DISP_GRS[b] .store(gr.to_bits(), Ordering::Relaxed); + } + + // Suppress unused variable warning for sr + let _ = sr; + } + + pub fn update_config(&mut self, cfg: ProcessorConfig) { + let sr = cfg.sample_rate as f32; + let nb = cfg.num_bands.clamp(2, 6); + self.crossover.recalculate(&cfg.crossover_freqs, nb); + for i in 0..6 { + self.compressors[i].update_params(&cfg.bands[i], sr); + } + self.limiter.update_threshold(cfg.limiter_threshold); + self.cfg = cfg; + } +} + +pub type SharedDsp = Arc>; + +pub fn nuevo_shared_dsp(cfg: ProcessorConfig) -> SharedDsp { + Arc::new(Mutex::new(DspProcessor::new(cfg))) +} diff --git a/src/processor/presets.rs b/src/processor/presets.rs new file mode 100644 index 0000000..9799ac5 --- /dev/null +++ b/src/processor/presets.rs @@ -0,0 +1,238 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use super::{BandConfig, ProcessorConfig}; + +// ── Rutas ───────────────────────────────────────────────────────────────────── + +fn presets_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("gradio") + .join("presets") +} + +// ── Struct ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Preset { + pub name: String, + pub num_bands: usize, + pub crossover_freqs: [f32; 5], + pub input_gain_db: f32, + pub output_gain_db: f32, + pub limiter_threshold: f32, + pub bands: [BandConfig; 6], +} + +impl Preset { + /// Crea un preset desde el estado actual del config. + pub fn from_config(name: &str, cfg: &ProcessorConfig) -> Self { + Self { + name: name.to_string(), + num_bands: cfg.num_bands, + crossover_freqs: cfg.crossover_freqs, + input_gain_db: cfg.input_gain_db, + output_gain_db: cfg.output_gain_db, + limiter_threshold: cfg.limiter_threshold, + bands: cfg.bands.clone(), + } + } + + /// Aplica el preset al config preservando sample_rate y enabled. + pub fn apply(&self, cfg: &mut ProcessorConfig) { + cfg.num_bands = self.num_bands; + cfg.crossover_freqs = self.crossover_freqs; + cfg.input_gain_db = self.input_gain_db; + cfg.output_gain_db = self.output_gain_db; + cfg.limiter_threshold = self.limiter_threshold; + cfg.bands = self.bands.clone(); + } + + /// Guarda el preset de usuario en disco. + pub fn save(&self) -> anyhow::Result<()> { + let dir = presets_dir(); + std::fs::create_dir_all(&dir)?; + let fname = sanitize_name(&self.name) + ".json"; + let json = serde_json::to_string_pretty(self)?; + std::fs::write(dir.join(fname), json)?; + Ok(()) + } + + /// Elimina el preset de usuario del disco. + pub fn delete(name: &str) { + let path = presets_dir().join(sanitize_name(name) + ".json"); + let _ = std::fs::remove_file(path); + } +} + +fn sanitize_name(name: &str) -> String { + name.chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect() +} + +// ── Carga ───────────────────────────────────────────────────────────────────── + +/// Presets de usuario guardados en disco. +pub fn load_user_presets() -> Vec { + let dir = presets_dir(); + let Ok(entries) = std::fs::read_dir(&dir) else { return vec![]; }; + let mut v: Vec = entries + .filter_map(|e| { + let path = e.ok()?.path(); + if path.extension()?.to_str()? != "json" { return None; } + let data = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&data).ok() + }) + .collect(); + v.sort_by(|a, b| a.name.cmp(&b.name)); + v +} + +/// Todos los presets disponibles: fábrica primero, usuario al final. +pub fn all_presets() -> Vec { + let mut v = builtin_presets(); + v.extend(load_user_presets()); + v +} + +// ── Presets de fábrica ──────────────────────────────────────────────────────── + +pub fn builtin_presets() -> Vec { + vec![ + preset_rock(), + preset_blues(), + preset_classic(), + preset_voice(), + preset_hard_bass(), + preset_strong(), + ] +} + +fn b( + threshold: f32, ratio: f32, + attack: f32, release: f32, + makeup: f32, clip: f32, +) -> BandConfig { + BandConfig { + enabled: true, threshold_db: threshold, ratio, + attack_ms: attack, release_ms: release, makeup_db: makeup, + clip_enable: true, clip_level_db: clip, + exp_enable: false, exp_threshold: -60.0, exp_ratio: 2.0, + } +} + +/// ROCK — Compresión agresiva, presencia mid, punch en graves. +fn preset_rock() -> Preset { + Preset { + name: "ROCK".into(), + num_bands: 5, + crossover_freqs: [120.0, 400.0, 2_000.0, 6_000.0, 0.0], + input_gain_db: 0.0, output_gain_db: 0.0, + limiter_threshold: -0.3, + bands: [ + b(-24.0, 4.0, 15.0, 200.0, 4.0, -0.5), // <120 Hz — sub controlado + b(-20.0, 4.0, 8.0, 150.0, 5.0, -0.5), // 120–400 — bajo punch + b(-18.0, 5.0, 5.0, 100.0, 6.0, -0.5), // 400–2k — presencia rock + b(-20.0, 4.0, 3.0, 80.0, 4.0, -0.5), // 2k–6k — agresividad + b(-24.0, 3.0, 2.0, 60.0, 3.0, -1.0), // >6k — aire controlado + b(-30.0, 2.0, 30.0, 500.0, 0.0, -2.0), // B6 inactiva + ], + } +} + +/// BLUE — Cálido, dinámico, jazz/blues. Menor compresión, sonido natural. +fn preset_blues() -> Preset { + Preset { + name: "BLUE".into(), + num_bands: 5, + crossover_freqs: [120.0, 400.0, 2_000.0, 6_000.0, 0.0], + input_gain_db: 0.0, output_gain_db: 0.0, + limiter_threshold: -0.5, + bands: [ + b(-26.0, 2.5, 25.0, 300.0, 2.5, -1.0), + b(-22.0, 3.0, 12.0, 200.0, 3.0, -1.0), + b(-20.0, 2.5, 8.0, 150.0, 2.0, -1.5), + b(-22.0, 2.0, 5.0, 100.0, 2.0, -2.0), + b(-28.0, 2.0, 3.0, 80.0, 1.5, -2.0), + b(-30.0, 2.0, 30.0, 500.0, 0.0, -2.0), + ], + } +} + +/// CLASIC — Clásica/orquestal. Transparente, dinámica amplia, toque suave. +fn preset_classic() -> Preset { + Preset { + name: "CLASIC".into(), + num_bands: 5, + crossover_freqs: [120.0, 400.0, 2_000.0, 6_000.0, 0.0], + input_gain_db: 0.0, output_gain_db: -1.0, + limiter_threshold: -1.0, + bands: [ + b(-16.0, 2.0, 50.0, 600.0, 1.0, -2.0), + b(-16.0, 1.8, 30.0, 500.0, 1.0, -2.0), + b(-14.0, 1.5, 20.0, 400.0, 1.0, -2.0), + b(-18.0, 1.5, 10.0, 300.0, 1.0, -2.0), + b(-22.0, 2.0, 5.0, 200.0, 2.0, -2.0), + b(-30.0, 2.0, 30.0, 500.0, 0.0, -2.0), + ], + } +} + +/// VOICE — Voz para radio FM: presencia 1–4kHz, de-essing, sub cortado. +fn preset_voice() -> Preset { + Preset { + name: "VOICE".into(), + num_bands: 5, + crossover_freqs: [120.0, 400.0, 2_000.0, 6_000.0, 0.0], + input_gain_db: 0.0, output_gain_db: 0.0, + limiter_threshold: -0.2, + bands: [ + b(-18.0, 4.0, 10.0, 150.0, 2.0, -1.0), // <120 — cortar boomy + b(-16.0, 5.0, 6.0, 120.0, 3.0, -0.5), // 120–400 — firmeza + b(-14.0, 6.0, 4.0, 100.0, 7.0, -0.5), // 400–2k — presencia voz + b(-16.0, 8.0, 2.0, 60.0, 5.0, -0.5), // 2k–6k — de-essing + b(-22.0, 4.0, 1.5, 40.0, 2.0, -1.0), // >6k — aire suave + b(-30.0, 2.0, 30.0, 500.0, 0.0, -2.0), + ], + } +} + +/// HARD BASS — Electrónica/Hip-hop. Sub amplificado al máximo, 6 bandas. +fn preset_hard_bass() -> Preset { + Preset { + name: "HARD BASS".into(), + num_bands: 6, + crossover_freqs: [80.0, 320.0, 1_200.0, 4_800.0, 12_000.0], + input_gain_db: 0.0, output_gain_db: -2.0, + limiter_threshold: -0.2, + bands: [ + b(-16.0, 8.0, 3.0, 80.0, 12.0, -0.3), // <80 — sub bestial + b(-18.0, 6.0, 5.0, 100.0, 9.0, -0.3), // 80–320 — bajo punch + b(-20.0, 4.0, 4.0, 100.0, 4.0, -0.5), // 320–1.2k + b(-22.0, 3.5, 3.0, 80.0, 3.0, -0.5), // 1.2k–4.8k + b(-26.0, 3.0, 2.0, 60.0, 2.0, -1.0), // 4.8k–12k + b(-28.0, 2.5, 2.0, 50.0, 1.5, -1.5), // >12k + ], + } +} + +/// STRONG — Máxima densidad/loudness, radio genérica, muro de sonido. +fn preset_strong() -> Preset { + Preset { + name: "STRONG".into(), + num_bands: 5, + crossover_freqs: [120.0, 400.0, 2_000.0, 6_000.0, 0.0], + input_gain_db: 0.0, output_gain_db: 0.0, + limiter_threshold: -0.1, + bands: [ + b(-30.0, 8.0, 5.0, 100.0, 10.0, -0.3), + b(-28.0, 8.0, 4.0, 80.0, 10.0, -0.3), + b(-26.0, 8.0, 3.0, 70.0, 10.0, -0.3), + b(-28.0, 6.0, 2.0, 60.0, 8.0, -0.3), + b(-30.0, 5.0, 1.5, 50.0, 7.0, -0.3), + b(-30.0, 2.0, 30.0, 500.0, 0.0, -2.0), + ], + } +} diff --git a/src/processor/ui/band_strip.rs b/src/processor/ui/band_strip.rs new file mode 100644 index 0000000..5d5b8d1 --- /dev/null +++ b/src/processor/ui/band_strip.rs @@ -0,0 +1,109 @@ +use gtk4::prelude::*; +use gtk4::{Box as GtkBox, CheckButton, DrawingArea, Label, Orientation, Separator}; +use std::sync::Arc; + +use crate::processor::AtomicF32; +use super::knob::Knob; +use super::meter::Meter; + +#[derive(Clone)] +pub struct BandStrip { + pub widget: GtkBox, + pub da_vu: DrawingArea, + pub da_gr: DrawingArea, + pub freq_label: Label, // actualizable cuando cambia num_bands + pub threshold: Knob, + pub ratio: Knob, + pub attack: Knob, + pub release: Knob, + pub makeup: Knob, + pub clip_lvl: Knob, + pub clip_en: CheckButton, + pub enabled: CheckButton, +} + +impl BandStrip { + pub fn new( + band_idx: usize, + freq_label: &str, + level_arc: Arc, + gr_arc: Arc, + ) -> Self { + let col = GtkBox::builder() + .orientation(Orientation::Vertical).spacing(2) + .hexpand(true) // cada banda toma igual ancho + .css_classes(["band-strip"]).build(); + + // Cabecera + col.append(&Label::builder() + .label(&format!("B{}", band_idx + 1)) + .css_classes(["band-num"]).build()); + let freq_lbl = Label::builder() + .label(freq_label).css_classes(["band-freq"]).build(); + col.append(&freq_lbl); + + // Etiquetas metro + let lbl_row = GtkBox::builder() + .orientation(Orientation::Horizontal).spacing(4) + .halign(gtk4::Align::Center).build(); + lbl_row.append(&Label::builder().label("VU").css_classes(["meter-lbl"]).build()); + lbl_row.append(&Label::builder().label("GR").css_classes(["meter-lbl"]).build()); + col.append(&lbl_row); + + // Metros + let meter_vu = Meter::new_level(level_arc); + let meter_gr = Meter::new_gr(gr_arc); + let da_vu = meter_vu.widget.clone(); + let da_gr = meter_gr.widget.clone(); + let m_row = GtkBox::builder() + .orientation(Orientation::Horizontal).spacing(2) + .halign(gtk4::Align::Center).build(); + m_row.append(&meter_vu.widget); + m_row.append(&meter_gr.widget); + col.append(&m_row); + + col.append(&Separator::new(Orientation::Horizontal)); + + // Knobs + let threshold = Knob::new("THR", "dB", -60.0, 0.0, -18.0); + let ratio = Knob::new("RATIO", ":1", 1.0, 20.0, 3.0); + let attack = Knob::new("ATK", "ms", 0.1, 200.0, 10.0); + let release = Knob::new("REL", "ms", 10.0, 2000.0, 150.0); + let makeup = Knob::new("GAIN", "dB", 0.0, 24.0, 3.0); + col.append(&threshold.widget); + col.append(&ratio.widget); + col.append(&attack.widget); + col.append(&release.widget); + col.append(&makeup.widget); + + col.append(&Separator::new(Orientation::Horizontal)); + + let clip_en = CheckButton::builder().label("CLIP").active(true) + .css_classes(["band-check"]).build(); + let clip_lvl = Knob::new("LVL", "dB", -12.0, 0.0, -0.5); + col.append(&clip_en); + col.append(&clip_lvl.widget); + + col.append(&Separator::new(Orientation::Horizontal)); + + let enabled = CheckButton::builder().label("ON").active(true) + .css_classes(["band-check"]).build(); + col.append(&enabled); + + Self { + widget: col, da_vu, da_gr, freq_label: freq_lbl, + threshold, ratio, attack, release, makeup, clip_lvl, clip_en, enabled, + } + } + + /// Actualiza la etiqueta de rango de frecuencias de esta banda. + pub fn set_freq_label(&self, text: &str) { + self.freq_label.set_text(text); + } + + /// Activa o desactiva visualmente la banda (bandas fuera de rango = apagadas). + pub fn set_active(&self, active: bool) { + self.widget.set_sensitive(active); + self.widget.set_opacity(if active { 1.0 } else { 0.22 }); + } +} diff --git a/src/processor/ui/knob.rs b/src/processor/ui/knob.rs new file mode 100644 index 0000000..8f785be --- /dev/null +++ b/src/processor/ui/knob.rs @@ -0,0 +1,178 @@ +use gtk4::prelude::*; +use gtk4::{DrawingArea, EventControllerScroll, GestureDrag}; +use std::cell::RefCell; +use std::rc::Rc; + +const KNOB_SIZE: i32 = 64; +// Arco total: 270° centrado abajo. Start en 135° (225° desde las 3 en punto = abajo-izq) +const ARC_START: f64 = std::f64::consts::PI * 0.75; // 135° +const ARC_END: f64 = std::f64::consts::PI * 2.25; // 405° + +#[derive(Clone)] +pub struct Knob { + pub widget: DrawingArea, + state: Rc>, +} + +struct KnobState { + value: f32, + min: f32, + max: f32, + default: f32, + label: String, + unit: String, + // Callback de usuario. Solo se dispara ante interacción humana (drag/scroll), + // NO al llamar set_value() desde código (evita ciclos). + on_change: Option>, +} + +impl Knob { + pub fn new(label: &str, unit: &str, min: f32, max: f32, default: f32) -> Self { + let da = DrawingArea::builder() + .width_request(KNOB_SIZE) + .height_request(KNOB_SIZE + 16) + .focusable(true) + .build(); + + let state = Rc::new(RefCell::new(KnobState { + value: default, min, max, default, + label: label.to_string(), unit: unit.to_string(), + on_change: None, + })); + + // ── Dibujado ───────────────────────────────────────────────────────── + { + let s = state.clone(); + da.set_draw_func(move |_da, cr, w, h| draw_knob(cr, w, h, &s.borrow())); + } + + // ── Scroll del ratón ───────────────────────────────────────────────── + let scroll = EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); + { + let sc = state.clone(); + let da_sc = da.clone(); + scroll.connect_scroll(move |_, _dx, dy| { + let new_val = { + let mut s = sc.borrow_mut(); + let step = (s.max - s.min) / 200.0; + s.value = (s.value - dy as f32 * step).clamp(s.min, s.max); + s.value + }; + da_sc.queue_draw(); + // Disparar callback fuera del borrow + if let Some(ref cb) = sc.borrow().on_change { cb(new_val); } + glib::Propagation::Stop + }); + } + da.add_controller(scroll); + + // ── Drag vertical ──────────────────────────────────────────────────── + let drag = GestureDrag::new(); + { + let sd = state.clone(); + let da_d = da.clone(); + drag.connect_drag_update(move |_, _dx, dy| { + let new_val = { + let mut s = sd.borrow_mut(); + let range = s.max - s.min; + s.value = (s.value + -dy as f32 * range / 200.0).clamp(s.min, s.max); + s.value + }; + da_d.queue_draw(); + if let Some(ref cb) = sd.borrow().on_change { cb(new_val); } + }); + } + da.add_controller(drag); + + Self { widget: da, state } + } + + /// Valor actual del knob. + pub fn value(&self) -> f32 { + self.state.borrow().value + } + + /// Establece el valor sin disparar el callback (para inicialización desde config). + pub fn set_value(&self, v: f32) { + let clamped = { + let s = self.state.borrow(); + v.clamp(s.min, s.max) + }; + self.state.borrow_mut().value = clamped; + self.widget.queue_draw(); + } + + /// Registra el callback que se invoca cuando el usuario mueve el knob. + /// El closure recibe el nuevo valor. Solo puede registrarse uno; una llamada + /// posterior reemplaza al anterior. + pub fn connect_changed(&self, cb: F) { + self.state.borrow_mut().on_change = Some(Box::new(cb)); + } +} + +// ── Dibujo Cairo ────────────────────────────────────────────────────────────── + +fn draw_knob(cr: &cairo::Context, w: i32, h: i32, s: &KnobState) { + let cx = w as f64 / 2.0; + let cy = (h - 16) as f64 / 2.0 + 8.0; + let r = ((KNOB_SIZE as f64 / 2.0) - 4.0).min(cx - 2.0).min(cy - 2.0); + + // Fondo del knob + cr.set_source_rgb(0.22, 0.22, 0.26); + cr.arc(cx, cy, r, 0.0, std::f64::consts::TAU); + let _ = cr.fill_preserve(); + cr.set_source_rgb(0.38, 0.38, 0.44); + cr.set_line_width(1.5); + let _ = cr.stroke(); + + // Arco de rango (fondo gris) + let ra = r - 5.0; + cr.set_source_rgb(0.15, 0.15, 0.18); + cr.set_line_width(4.0); + cr.arc(cx, cy, ra, ARC_START, ARC_END); + let _ = cr.stroke(); + + // Arco de valor (ámbar) + let t = ((s.value - s.min) / (s.max - s.min)) as f64; + let angle_val = ARC_START + t * (ARC_END - ARC_START); + if angle_val > ARC_START + 0.02 { + cr.set_source_rgb(0.91, 0.51, 0.04); + cr.set_line_width(4.0); + cr.arc(cx, cy, ra, ARC_START, angle_val); + let _ = cr.stroke(); + } + + // Indicador central + cr.set_source_rgb(0.95, 0.95, 0.98); + cr.set_line_width(2.0); + cr.move_to(cx, cy); + cr.line_to(cx + (r - 8.0) * angle_val.cos(), cy + (r - 8.0) * angle_val.sin()); + let _ = cr.stroke(); + + // Etiqueta nombre (arriba) + cr.set_source_rgb(0.55, 0.60, 0.65); + cr.select_font_face("Monospace", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + cr.set_font_size(8.5); + let lbl = &s.label; + let x_lbl = if let Ok(e) = cr.text_extents(lbl) { cx - e.width()/2.0 } else { 2.0 }; + cr.move_to(x_lbl, 10.0); + let _ = cr.show_text(lbl); + + // Valor numérico (abajo) + let val_str = fmt_val(s.value, &s.unit); + cr.set_source_rgb(0.91, 0.51, 0.04); + cr.set_font_size(8.0); + let x_val = if let Ok(e) = cr.text_extents(&val_str) { cx - e.width()/2.0 } else { 2.0 }; + cr.move_to(x_val, h as f64 - 2.0); + let _ = cr.show_text(&val_str); +} + +fn fmt_val(v: f32, unit: &str) -> String { + if unit == ":1" { + if v >= 10.0 { format!("{:.0}:1", v) } else { format!("{:.1}:1", v) } + } else if unit == "ms" { + if v >= 100.0 { format!("{:.0}ms", v) } else { format!("{:.1}ms", v) } + } else { + format!("{:.1}{}", v, unit) + } +} diff --git a/src/processor/ui/meter.rs b/src/processor/ui/meter.rs new file mode 100644 index 0000000..f18a511 --- /dev/null +++ b/src/processor/ui/meter.rs @@ -0,0 +1,100 @@ +use gtk4::prelude::*; +use gtk4::DrawingArea; +use std::sync::Arc; + +use crate::processor::AtomicF32; + +const METER_W: i32 = 16; +const METER_H: i32 = 120; + +// Segmentos del vúmetro: (umbral 0..1, R, G, B) +const SEGMENTS: &[(f32, f64, f64, f64)] = &[ + (0.75, 0.0, 0.87, 0.25), // verde + (0.88, 0.89, 0.78, 0.0 ), // amarillo + (0.95, 0.91, 0.42, 0.02), // naranja + (1.0, 0.88, 0.07, 0.04), // rojo +]; + +/// Vúmetro LED vertical. `value` es un AtomicF32 en rango 0..1. +#[derive(Clone)] +pub struct Meter { + pub widget: DrawingArea, +} + +impl Meter { + /// Meter de nivel (fondo oscuro, verde→rojo de abajo a arriba) + pub fn new_level(value: Arc) -> Self { + Self::build(value, false) + } + + /// Meter de gain reduction (fondo oscuro, azul de arriba a abajo) + pub fn new_gr(value: Arc) -> Self { + Self::build(value, true) + } + + fn build(value: Arc, is_gr: bool) -> Self { + let da = DrawingArea::builder() + .width_request(METER_W) + .height_request(METER_H) + .build(); + + da.set_draw_func(move |_da, cr, _w, h| { + let v = value.load().clamp(0.0, 1.0) as f64; + draw_meter(cr, h, v, is_gr); + }); + + Self { widget: da } + } +} + +fn draw_meter(cr: &cairo::Context, h: i32, value: f64, is_gr: bool) { + let w = METER_W as f64; + let hf = h as f64; + let nseg = 30usize; + let seg_h = hf / nseg as f64; + let gap = 1.5; + + let lit_count = (value * nseg as f64).round() as usize; + + for i in 0..nseg { + // El segmento 0 está abajo; nseg-1 arriba + let seg_idx = if is_gr { nseg - 1 - i } else { i }; + let y = hf - (i + 1) as f64 * seg_h + gap * 0.5; + let sh = seg_h - gap; + + let lit = if is_gr { + // GR: de arriba hacia abajo + i >= nseg - lit_count + } else { + // Nivel: de abajo hacia arriba + i < lit_count + }; + + // Color según posición del segmento (no del valor actual) + let frac = seg_idx as f64 / nseg as f64; + let (r, g, b) = color_for_frac(frac, is_gr); + + if lit { + cr.set_source_rgb(r, g, b); + } else { + // Segmento apagado: versión muy oscura del color + cr.set_source_rgb(r * 0.12, g * 0.12, b * 0.12); + } + + cr.rectangle(1.5, y, w - 3.0, sh); + let _ = cr.fill(); + } +} + +fn color_for_frac(frac: f64, is_gr: bool) -> (f64, f64, f64) { + if is_gr { + // GR: azul siempre + return (0.25, 0.45, 1.0); + } + for &(thr, r, g, b) in SEGMENTS { + if frac <= thr as f64 { + return (r, g, b); + } + } + (0.88, 0.07, 0.04) +} diff --git a/src/processor/ui/mod.rs b/src/processor/ui/mod.rs new file mode 100644 index 0000000..01b27af --- /dev/null +++ b/src/processor/ui/mod.rs @@ -0,0 +1,17 @@ +pub mod knob; +pub mod meter; +pub mod band_strip; + +pub fn band_freq_labels(freqs: &[f32; 5]) -> Vec { + let active = (0..5).filter(|&i| freqs[i] > 0.0).count() + 1; + (0..6).map(|b| { + if b == 0 { format!("<{}", fmt_hz(freqs[0])) } + else if b >= active { "—".to_string() } + else if b == active - 1 { format!(">{}", fmt_hz(freqs[b-1])) } + else { format!("{}-{}", fmt_hz(freqs[b-1]), fmt_hz(freqs[b])) } + }).collect() +} + +fn fmt_hz(f: f32) -> String { + if f >= 1000.0 { format!("{:.0}k", f/1000.0) } else { format!("{:.0}", f) } +} diff --git a/src/relay.rs b/src/relay.rs new file mode 100644 index 0000000..5e0059d --- /dev/null +++ b/src/relay.rs @@ -0,0 +1,297 @@ +// relay.rs — Módulo de relay internet para gr-client +// +// Mantiene una conexión WebSocket persistente a un servidor relay externo +// registrada con el ID de 8 dígitos configurado. Cuando gr-client se conecta +// al relay con ese ID, este módulo hace bridge entre el WS del relay y el +// servidor TCP local de servidor.rs, de forma transparente. +// +// La URL del relay se toma de la variable de entorno GRADIO_RELAY_URL. +// Si no está definida, se usa DEFAULT_RELAY_URL. Para autohospedar el relay, +// definir GRADIO_RELAY_URL=wss://mi-servidor.ejemplo/ws antes de lanzar. +// +// Flujo: +// 1. Se conecta a la URL configurada +// 2. Envía {"tipo":"register","id":"XXXXXXXX"} +// 3. Recibe {"tipo":"registered","id":"XXXXXXXX"} +// 4. Espera frames WS del relay (originados en gr-client) +// 5. Cada frame WS → línea JSON → TCP servidor.rs local +// Cada línea TCP → frame WS → relay → gr-client +// 6. Si detecta un frame Auth, abre una conexión TCP fresca +// 7. Reconecta con backoff exponencial si la WS cae + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use futures_util::{SinkExt, StreamExt}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use crate::servidor::ClienteConectado; + +const DEFAULT_RELAY_URL: &str = "wss://relay.example.com/ws"; + +// Tiempo máximo para que un envío individual al relay complete. Cubre el caso +// de una conexión "zombi": el TCP sigue ESTAB a nivel de SO (sin RST/FIN, p.ej. +// tras un corte de red silencioso o cambio de IP pública del router) pero los +// bytes ya no llegan al otro lado. Sin este timeout, un solo .send() colgado +// para siempre bloqueaba el único punto de escritura compartido y el relay +// terminaba desregistrando el ID por falta de Pong — sin que este lado se +// enterara nunca, porque nada devolvía error. +const SEND_TIMEOUT: Duration = Duration::from_secs(15); + +fn relay_url() -> String { + std::env::var("GRADIO_RELAY_URL").unwrap_or_else(|_| DEFAULT_RELAY_URL.to_string()) +} + +/// Lanza el hilo de relay. No bloquea — retorna inmediatamente. +/// `id` — 8 dígitos numéricos, `puerto_local` — puerto del servidor TCP local. +pub fn iniciar(id: String, puerto_local: u16, cliente_conectado: ClienteConectado) { + std::thread::Builder::new() + .name("gradio-relay".to_string()) + .spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("relay: no se pudo crear runtime tokio"); + rt.block_on(bucle_relay(id, puerto_local, cliente_conectado)); + }) + .expect("relay: no se pudo crear hilo"); +} + +// ─── Bucle de reconexión ────────────────────────────────────────────────────── + +async fn bucle_relay(id: String, puerto_local: u16, cliente_conectado: ClienteConectado) { + loop { + log::info!("relay: conectando con ID={}", id); + match conectar_y_operar(&id, puerto_local, cliente_conectado.clone()).await { + Ok(()) => { + log::info!("relay: sesión cerrada, reconectando en 1s..."); + tokio::time::sleep(Duration::from_secs(1)).await; + } + Err(e) => { + log::warn!("relay: {e}. Reintentando en 30s..."); + tokio::time::sleep(Duration::from_secs(30)).await; + } + } + } +} + +// ─── Conexión principal ─────────────────────────────────────────────────────── + +async fn conectar_y_operar(id: &str, puerto_local: u16, cliente_conectado: ClienteConectado) -> Result<()> { + // connect_async maneja TLS automáticamente para wss:// (feature native-tls) + let url = relay_url(); + log::info!("relay: usando URL {}", url); + let (ws, _) = connect_async(url).await?; + + let (ws_sink, mut ws_rx) = ws.split(); + + // ── Tarea de escritura única ────────────────────────────────────────────── + // Todo envío al relay pasa por este canal en vez de golpear el sink + // directamente desde varios sitios. Cada .send() real tiene SEND_TIMEOUT: + // si se cuelga (conexión zombi), la tarea avisa por `murio_tx` y termina, + // en lugar de quedar sosteniendo el punto de escritura para siempre. + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (murio_tx, mut murio_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + tokio::spawn(async move { + let mut ws_sink = ws_sink; + while let Some(msg) = out_rx.recv().await { + match tokio::time::timeout(SEND_TIMEOUT, ws_sink.send(msg)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => { + log::warn!("relay: error enviando al relay: {e}"); + let _ = murio_tx.send(()); + break; + } + Err(_) => { + log::warn!("relay: envío al relay colgado >{}s (conexión zombi)", SEND_TIMEOUT.as_secs()); + let _ = murio_tx.send(()); + break; + } + } + } + }); + + // ── Handshake de registro ───────────────────────────────────────────────── + let msg = format!(r#"{{"tipo":"register","id":"{}"}}"#, id); + out_tx.send(Message::Text(msg.into())) + .map_err(|_| anyhow::anyhow!("relay: canal de escritura cerrado antes de registrar"))?; + + let confirmacion = tokio::time::timeout(SEND_TIMEOUT, ws_rx.next()).await + .map_err(|_| anyhow::anyhow!("timeout esperando confirmación de registro"))? + .ok_or_else(|| anyhow::anyhow!("relay cerró antes de confirmar registro"))??; + + match &confirmacion { + Message::Text(t) => { + let v: serde_json::Value = serde_json::from_str(t) + .map_err(|_| anyhow::anyhow!("respuesta de registro no es JSON: {t}"))?; + if v["tipo"].as_str() != Some("registered") { + return Err(anyhow::anyhow!("respuesta inesperada: {t}")); + } + log::info!("relay: registrado con ID={id}"); + } + _ => return Err(anyhow::anyhow!("respuesta de registro no es texto")), + } + + // ── Canal TCP→WS ────────────────────────────────────────────────────────── + let (tcp_to_ws_tx, mut tcp_to_ws_rx) = + tokio::sync::mpsc::unbounded_channel::(); + + let out_tx_fwd = out_tx.clone(); + tokio::spawn(async move { + while let Some(linea) = tcp_to_ws_rx.recv().await { + let texto = linea.trim_end_matches(['\n', '\r']).to_string(); + if texto.is_empty() { continue; } + if out_tx_fwd.send(Message::Text(texto.into())).is_err() { + break; + } + } + }); + + // ── Estado del bridge ───────────────────────────────────────────────────── + let mut tcp_write: Option = None; + // Epoch: incrementado cada vez que se abre un TCP nuevo. + // Evita que el signal tcp_caido de una sesión anterior mate la actual. + let epoch_actual = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (tcp_caido_tx, mut tcp_caido_rx) = + tokio::sync::mpsc::unbounded_channel::(); // lleva el epoch + + // Timer de ping WS — detecta clientes caídos sin cerrar la conexión limpiamente + let mut ping_interval = tokio::time::interval(Duration::from_secs(45)); + ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + // Timer de ping TCP local — evita el timeout de inactividad de servidor.rs (90s) + // cuando el cliente Android está conectado pero sin interacción. + let mut ping_tcp_interval = tokio::time::interval(Duration::from_secs(60)); + ping_tcp_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + // ── Bucle principal ─────────────────────────────────────────────────────── + loop { + tokio::select! { + // Ping periódico al relay para mantener la WS viva ante NAT/firewall + _ = ping_interval.tick() => { + if out_tx.send(Message::Ping(vec![].into())).is_err() { + return Ok(()); // canal de escritura cerrado → reconectar + } + } + // La tarea de escritura murió (envío colgado o error real) → reconectar + Some(()) = murio_rx.recv() => { + return Err(anyhow::anyhow!( + "escritura al relay sin respuesta por más de {}s (conexión zombi)", + SEND_TIMEOUT.as_secs() + )); + } + // Ping periódico al servidor TCP local — reinicia el timer de + // inactividad de servidor.rs (90s) cuando el cliente está idle. + _ = ping_tcp_interval.tick() => { + if let Some(ref mut tw) = tcp_write { + if tw.write_all(b"{\"tipo\":\"Ping\"}\n").await.is_err() { + tcp_write = None; + } + } + } + // TCP al servidor local se cerró — si el epoch coincide, reconectar WS para + // garantizar estado limpio en el relay antes del próximo cliente. + Some(ep) = tcp_caido_rx.recv() => { + if ep == epoch_actual.load(std::sync::atomic::Ordering::Relaxed) { + log::info!("relay: cliente desconectado (ep={}), reconectando WS...", ep); + cliente_conectado.lock().unwrap().clear(); + return Ok(()); // fuerza re-registro limpio con el relay + } else { + log::debug!("relay: tcp_caido obsoleto ignorado (ep={} vs actual={})", ep, + epoch_actual.load(std::sync::atomic::Ordering::Relaxed)); + } + } + + // Frame WS del relay (viene de gr-client) + resultado = ws_rx.next() => { + let frame = match resultado { + Some(Ok(f)) => f, + Some(Err(e)) => return Err(e.into()), + None => return Ok(()), // relay cerró + }; + + match frame { + Message::Close(_) => return Ok(()), + Message::Ping(d) => { + let _ = out_tx.send(Message::Pong(d)); + } + Message::Text(texto) => { + // Filtrar mensajes de control del relay (no son del cliente Android) + let tipo_frame = serde_json::from_str::(&texto) + .ok() + .and_then(|v| v["tipo"].as_str().map(|t| t.to_string())) + .unwrap_or_default(); + match tipo_frame.as_str() { + "connected" | "disconnected" | "registered" => { + log::debug!("relay: mensaje de control ignorado: {tipo_frame}"); + continue; + } + "error" => { + log::warn!("relay: error del relay: {texto}"); + return Ok(()); // reconectar + } + _ => {} + } + + // Si el frame es Auth → nuevo cliente → abrir TCP fresco + let es_auth = tipo_frame == "Auth"; + + if es_auth { + // Incrementar epoch antes de reemplazar la conexión + let nuevo_ep = epoch_actual.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + log::debug!("relay: nuevo cliente (Auth) — TCP fresco (ep={})", nuevo_ep); + tcp_write = None; // cierra write half anterior → envía EOF a servidor.rs + *cliente_conectado.lock().unwrap() = "Internet".to_string(); + } + + // Si no hay TCP activo, abrir uno al servidor local + if tcp_write.is_none() { + match TcpStream::connect( + format!("127.0.0.1:{puerto_local}") + ).await { + Ok(tcp) => { + let ep = epoch_actual.load(std::sync::atomic::Ordering::Relaxed); + let (rh, wh) = tcp.into_split(); + tcp_write = Some(wh); + let tx_d = tcp_to_ws_tx.clone(); + let tx_c = tcp_caido_tx.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(rh); + let mut linea = String::new(); + loop { + linea.clear(); + match reader.read_line(&mut linea).await { + Ok(0) | Err(_) => break, + Ok(_) => { + if tx_d.send(linea.clone()).is_err() { break; } + } + } + } + let _ = tx_c.send(ep); // envía el epoch de esta sesión + }); + log::debug!("relay: TCP a 127.0.0.1:{puerto_local} abierto (ep={})", ep); + } + Err(e) => { + log::warn!("relay: no se pudo conectar a servidor local: {e}"); + continue; + } + } + } + + // Reenviar al servidor local + if let Some(ref mut tw) = tcp_write { + let linea = format!("{texto}\n"); + if tw.write_all(linea.as_bytes()).await.is_err() { + tcp_write = None; + } + } + } + _ => {} // Binary, Pong — ignorar + } + } + } + } +} diff --git a/src/servidor.rs b/src/servidor.rs new file mode 100644 index 0000000..5d404ae --- /dev/null +++ b/src/servidor.rs @@ -0,0 +1,1236 @@ +// servidor.rs — Servidor TCP para gr-client +// +// Escucha en el puerto configurado (7777 por defecto) y atiende conexiones +// remotas de gr-client. Todas las operaciones de datos se hacen sobre los +// archivos locales de ~/.gradio, exactamente igual que los procesos locales. +// +// Relay futuro: se conectará a un servidor relay externo como puente para clientes remotos. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::Result; +use chrono::{Datelike, NaiveDate}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::broadcast; + +pub type ClienteConectado = Arc>; + +// ─── Protocolo (igual que en gr-client/src/protocol.rs) ────────────────────── +// IMPORTANTE: este bloque debe mantenerse sincronizado con +// radio-player-client/src/protocol.rs + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntradaRemota { + pub ruta: String, + pub dias: String, + pub inicio: String, + pub fin: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackRemoto { + pub ruta: String, + pub duracion: String, + pub titulo: String, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultadoBusqueda { + pub ruta: String, + pub nombre: String, + pub duracion: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotonRemoto { + pub indice: u8, + pub etiqueta: String, + pub ruta: String, + pub color: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ItemTanda { + pub ruta: String, + pub nombre: String, + pub duracion_secs: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TandaRemota { + pub hora: u32, + pub minuto: u32, + pub items: Vec, + pub duracion_total_secs: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "tipo")] +pub enum MsgCliente { + Auth { token: String }, + Ping, + ListarDir { ruta: String }, + ObtenerPautaje { tipo_p: String, hora: u8, minuto: u8 }, + GuardarPautaje { tipo_p: String, hora: u8, minuto: u8, entradas: Vec }, + ObtenerParrilla { dia: u8, hora: u8 }, + GuardarParrilla { dia: u8, hora: u8, items: Vec }, + CopiarHoraParrilla { dia_orig: u8, hora_orig: u8, dia_dest: u8, hora_dest: u8 }, + ObtenerBotonera, + GuardarBotonera { botones: Vec }, + ObtenerPlaylist, + ObtenerEstado, + ComandoPlayer { cmd: String }, + SetVolumen { upvol: u8 }, + ReproducirAhora { ruta: String }, + AgregarAlPlaylist { ruta: String, duracion: String, #[serde(default)] al_inicio: bool }, + EliminarDePlaylist { indice: usize }, + MoverEnPlaylist { indice: usize, arriba: bool }, + Buscar { consulta: String, ruta: Option }, + ObtenerReportes { tipo_r: String, fecha: String }, + InfoAudio { ruta: String }, + ActualizarIndice, + PlaylistPlayAhora { ruta: String, duracion: String }, + StopGeneral, + VaciarComerciales, + VaciarEventos, + VaciarEventosEspera, + ObtenerCola { tipo_cola: String }, + EliminarDeCola { tipo_cola: String, indice: usize }, + ObtenerProximasTandas, + ReproducirTanda { hora: u32, minuto: u32, items: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "tipo")] +pub enum MsgServidor { + AuthOk { version: String }, + Pong, + Error { mensaje: String }, + DirectorioListado { ruta: String, entradas: Vec }, + PautajeObtenido { tipo_p: String, hora: u8, minuto: u8, entradas: Vec }, + PautajeGuardado, + ParrillaObtenida { dia: u8, hora: u8, items: Vec }, + ParrillaGuardada, + BotoneraObtenida { botones: Vec }, + BotoneraGuardada, + PlaylistObtenida { tracks: Vec }, + EstadoObtenido { estado: EstadoRemoto }, + InfoAudioObtenida { ruta: String, duracion: String }, + ResultadosBusqueda { resultados: Vec }, + ReportesObtenidos { lineas: Vec }, + PlaylistCambiada { tracks: Vec }, + EstadoCambiado { estado: EstadoRemoto }, + IndiceActualizado { ok: bool, mensaje: String }, + ColaObtenida { tipo_cola: String, items: Vec }, + ProximasTandasObtenidas { tandas: Vec }, + Ok, +} + +// ─── Servidor ──────────────────────────────────────────────────────────────── + +pub struct Servidor { + puerto: u16, + token: String, + cliente_conectado: ClienteConectado, +} + +impl Servidor { + pub fn nuevo(puerto: u16, token: String, cliente_conectado: ClienteConectado) -> Self { + Servidor { puerto, token, cliente_conectado } + } + + pub async fn ejecutar(self) -> Result<()> { + let addr = format!("0.0.0.0:{}", self.puerto); + let listener = TcpListener::bind(&addr).await?; + log::info!("gr-client servidor escuchando en {}", addr); + + let (tx_push, _) = broadcast::channel::(256); + let tx_push = Arc::new(tx_push); + + // Tarea vigilante: monitorea archivos locales y empuja cambios a clientes + let tx_vigil = tx_push.clone(); + tokio::spawn(async move { + vigilar_estado(tx_vigil).await; + }); + + loop { + let (stream, peer_addr) = match listener.accept().await { + Ok(s) => s, + Err(e) => { + log::warn!("Error aceptando conexión: {}", e); + continue; + } + }; + log::info!("gr-client: cliente conectado desde {}", peer_addr); + let rx_push = tx_push.subscribe(); + let token = self.token.clone(); + let cc = self.cliente_conectado.clone(); + tokio::spawn(async move { + // Conexiones del relay (127.0.0.1) se identifican aparte en relay.rs + let es_local = peer_addr.ip().is_loopback(); + if !es_local { + *cc.lock().unwrap() = peer_addr.ip().to_string(); + } + if let Err(e) = manejar_cliente(stream, rx_push, token).await { + log::warn!("gr-client: error cliente {}: {}", peer_addr, e); + } + log::info!("gr-client: cliente {} desconectado", peer_addr); + if !es_local { + cc.lock().unwrap().clear(); + } + }); + } + } +} + +// Tiempo máximo de inactividad: si no llega ningún mensaje (ni Ping) en este +// plazo, el servidor cierra la conexión para liberar el slot. +const TIMEOUT_INACTIVIDAD: Duration = Duration::from_secs(90); + +// ─── Manejo de cliente ──────────────────────────────────────────────────────── + +async fn manejar_cliente( + stream: TcpStream, + mut rx_push: broadcast::Receiver, + token_esperado: String, +) -> Result<()> { + let (read_half, mut write_half) = stream.into_split(); + let mut reader = BufReader::new(read_half); + let mut linea = String::new(); + + // Canal interno para enviar respuestas al cliente desde el manejador + let (tx_resp, mut rx_resp) = tokio::sync::mpsc::unbounded_channel::(); + + // Tarea escritora: envía respuestas + push + let escritor = tokio::spawn(async move { + loop { + tokio::select! { + Some(json) = rx_resp.recv() => { + if write_half.write_all(json.as_bytes()).await.is_err() { break; } + } + result = rx_push.recv() => { + match result { + Ok(push) => { + if write_half.write_all(push.as_bytes()).await.is_err() { break; } + } + // Lag en broadcast: el cliente es lento — ignorar mensajes perdidos + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + log::debug!("gr-client: push lagged {} mensajes, ignorados", n); + } + Err(_) => break, + } + } + } + } + }); + + // Autenticación (con timeout propio para no bloquear el accept) + linea.clear(); + match tokio::time::timeout(Duration::from_secs(15), reader.read_line(&mut linea)).await { + Err(_) | Ok(Ok(0)) | Ok(Err(_)) => { + escritor.abort(); + return Ok(()); + } + Ok(Ok(_)) => {} + } + match serde_json::from_str::(linea.trim()) { + Ok(MsgCliente::Auth { token }) => { + if !token_esperado.is_empty() && token != token_esperado { + let resp = serde_json::to_string(&MsgServidor::Error { + mensaje: "Token incorrecto".to_string(), + })? + "\n"; + let _ = tx_resp.send(resp); + tokio::time::sleep(Duration::from_millis(500)).await; + escritor.abort(); + return Ok(()); + } + let resp = serde_json::to_string(&MsgServidor::AuthOk { + version: env!("CARGO_PKG_VERSION").to_string(), + })? + "\n"; + let _ = tx_resp.send(resp); + } + _ => { + let resp = serde_json::to_string(&MsgServidor::Error { + mensaje: "Se esperaba Auth como primer mensaje".to_string(), + })? + "\n"; + let _ = tx_resp.send(resp); + tokio::time::sleep(Duration::from_millis(500)).await; + escritor.abort(); + return Ok(()); + } + } + + // Bucle principal con timeout de inactividad + loop { + linea.clear(); + let resultado = tokio::time::timeout( + TIMEOUT_INACTIVIDAD, + reader.read_line(&mut linea), + ).await; + + match resultado { + // Timeout de inactividad — cerrar conexión + Err(_) => { + log::info!("gr-client: timeout de inactividad ({}s) — cerrando conexión", + TIMEOUT_INACTIVIDAD.as_secs()); + break; + } + // EOF o error de red + Ok(Ok(0)) | Ok(Err(_)) => break, + Ok(Ok(_)) => { + let trimmed = linea.trim(); + if trimmed.is_empty() { continue; } + match serde_json::from_str::(trimmed) { + Ok(MsgCliente::Ping) => { + // Responder Pong — reinicia el timer de inactividad implícitamente + if let Ok(json) = serde_json::to_string(&MsgServidor::Pong) { + let _ = tx_resp.send(json + "\n"); + } + } + Ok(msg) => { + let respuestas = procesar_mensaje(msg).await; + for r in respuestas { + if let Ok(json) = serde_json::to_string(&r) { + let _ = tx_resp.send(json + "\n"); + } + } + } + Err(e) => { + log::warn!("gr-client: mensaje inválido: {} — {:?}", trimmed, e); + let err = MsgServidor::Error { mensaje: format!("JSON inválido: {}", e) }; + if let Ok(json) = serde_json::to_string(&err) { + let _ = tx_resp.send(json + "\n"); + } + } + } + } + } + } + escritor.abort(); + Ok(()) +} + +// ─── Procesamiento de mensajes ──────────────────────────────────────────────── + +async fn procesar_mensaje(msg: MsgCliente) -> Vec { + match msg { + MsgCliente::Auth { .. } => vec![MsgServidor::Error { + mensaje: "Auth duplicado".to_string(), + }], + + MsgCliente::Ping => vec![MsgServidor::Pong], + + MsgCliente::ListarDir { ruta } => vec![listar_directorio(&ruta)], + + MsgCliente::ObtenerPautaje { tipo_p, hora, minuto } => { + let entradas = leer_pautaje(&tipo_p, hora, minuto); + vec![MsgServidor::PautajeObtenido { tipo_p, hora, minuto, entradas }] + } + + MsgCliente::GuardarPautaje { tipo_p, hora, minuto, entradas } => { + escribir_pautaje(&tipo_p, hora, minuto, &entradas); + vec![MsgServidor::PautajeGuardado] + } + + MsgCliente::ObtenerParrilla { dia, hora } => { + let items = leer_parrilla(dia, hora); + vec![MsgServidor::ParrillaObtenida { dia, hora, items }] + } + + MsgCliente::GuardarParrilla { dia, hora, items } => { + escribir_parrilla(dia, hora, &items); + vec![MsgServidor::ParrillaGuardada] + } + + MsgCliente::CopiarHoraParrilla { dia_orig, hora_orig, dia_dest, hora_dest } => { + let items = leer_parrilla(dia_orig, hora_orig); + escribir_parrilla(dia_dest, hora_dest, &items); + vec![MsgServidor::ParrillaGuardada] + } + + MsgCliente::ObtenerBotonera => { + let botones = leer_botonera(); + vec![MsgServidor::BotoneraObtenida { botones }] + } + + MsgCliente::GuardarBotonera { botones } => { + escribir_botonera(&botones); + vec![MsgServidor::BotoneraGuardada] + } + + MsgCliente::ObtenerPlaylist => { + let tracks = leer_playlist(); + vec![MsgServidor::PlaylistObtenida { tracks }] + } + + MsgCliente::ObtenerEstado => { + let estado = leer_estado(); + vec![MsgServidor::EstadoObtenido { estado }] + } + + MsgCliente::ComandoPlayer { cmd } => { + ejecutar_comando_player(&cmd); + vec![MsgServidor::Ok] + } + + MsgCliente::SetVolumen { upvol } => { + let path = gradio_tmp("upvol"); + let _ = fs::write(path, upvol.to_string()); + vec![MsgServidor::Ok] + } + + MsgCliente::ReproducirAhora { ruta } => { + if !grpautaje::audio_probe::is_playable(Path::new(&ruta)) { + log::warn!("[srv] ReproducirAhora rechazado, audio inválido: {}", ruta); + return vec![MsgServidor::Error { mensaje: "audio inválido o corrupto".into() }]; + } + let path = gradio_tmp("botonera_now"); + let _ = fs::write(path, &ruta); + vec![MsgServidor::Ok] + } + + MsgCliente::PlaylistPlayAhora { ruta, duracion } => { + if !grpautaje::audio_probe::is_playable(Path::new(&ruta)) { + log::warn!("[srv] PlaylistPlayAhora rechazado, audio inválido: {}", ruta); + return vec![MsgServidor::Error { mensaje: "audio inválido o corrupto".into() }]; + } + let path = gradio_tmp("play_now"); + let content = format!("{}\t{}", ruta, duracion); + let _ = fs::write(path, content); + vec![MsgServidor::Ok] + } + + MsgCliente::AgregarAlPlaylist { ruta, duracion, al_inicio } => { + if !grpautaje::audio_probe::is_playable(Path::new(&ruta)) { + log::warn!("[srv] AgregarAlPlaylist rechazado, audio inválido: {}", ruta); + return vec![MsgServidor::Error { mensaje: "audio inválido o corrupto".into() }]; + } + agregar_al_playlist(&ruta, &duracion, al_inicio); + vec![MsgServidor::Ok] + } + + MsgCliente::EliminarDePlaylist { indice } => { + eliminar_de_playlist(indice); + vec![MsgServidor::Ok] + } + + MsgCliente::MoverEnPlaylist { indice, arriba } => { + mover_en_playlist(indice, arriba); + vec![MsgServidor::Ok] + } + + MsgCliente::Buscar { consulta, ruta } => { + let resultados = buscar_audio(&consulta, ruta.as_deref()).await; + vec![MsgServidor::ResultadosBusqueda { resultados }] + } + + MsgCliente::ObtenerReportes { tipo_r, fecha } => { + let lineas = generar_reporte(&tipo_r, &fecha); + vec![MsgServidor::ReportesObtenidos { lineas }] + } + + MsgCliente::InfoAudio { ruta } => { + let duracion = obtener_duracion_audio(&ruta).await; + vec![MsgServidor::InfoAudioObtenida { ruta, duracion }] + } + MsgCliente::StopGeneral => { + let tmp = gradio_home().join("tmp"); + let _ = fs::write(tmp.join("cmd_stop_general"), "1"); + vec![MsgServidor::Ok] + } + + MsgCliente::VaciarComerciales => { + let _ = fs::write(gradio_home().join("tmp").join("comercialeslist4"), ""); + let items = leer_cola("comerciales"); + vec![MsgServidor::ColaObtenida { tipo_cola: "comerciales".into(), items }] + } + + MsgCliente::VaciarEventos => { + let _ = fs::write(gradio_home().join("tmp").join("eventoslist"), ""); + let items = leer_cola("eventos"); + vec![MsgServidor::ColaObtenida { tipo_cola: "eventos".into(), items }] + } + + MsgCliente::VaciarEventosEspera => { + let _ = fs::write(gradio_home().join("tmp").join("eventos-esperalist"), ""); + let items = leer_cola("eventos_espera"); + vec![MsgServidor::ColaObtenida { tipo_cola: "eventos_espera".into(), items }] + } + + MsgCliente::ObtenerCola { tipo_cola } => { + let items = leer_cola(&tipo_cola); + vec![MsgServidor::ColaObtenida { tipo_cola, items }] + } + + MsgCliente::EliminarDeCola { tipo_cola, indice } => { + eliminar_de_cola(&tipo_cola, indice); + let items = leer_cola(&tipo_cola); + vec![MsgServidor::ColaObtenida { tipo_cola, items }] + } + + MsgCliente::ObtenerProximasTandas => { + let tandas = obtener_proximas_tandas(); + vec![MsgServidor::ProximasTandasObtenidas { tandas }] + } + + MsgCliente::ReproducirTanda { hora, minuto, items } => { + reproducir_tanda(hora, minuto, &items); + let cola = leer_cola("comerciales"); + vec![MsgServidor::ColaObtenida { tipo_cola: "comerciales".into(), items: cola }] + } + + MsgCliente::ActualizarIndice => { + // Usa una base de datos propia en ~/.gradio/data/locatedb + // para no necesitar sudo. Indexa el HOME del usuario. + let db_path = gradio_home().join("locatedb"); + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let resultado = tokio::process::Command::new("updatedb") + .arg("--output").arg(&db_path) + .arg("--localpaths").arg(&home) + .output() + .await; + match resultado { + Ok(out) if out.status.success() => { + log::info!("updatedb completado en {:?}", db_path); + vec![MsgServidor::IndiceActualizado { ok: true, mensaje: "Índice actualizado correctamente".to_string() }] + } + Ok(out) => { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let err = if err.is_empty() { + format!("exit code {}", out.status.code().unwrap_or(-1)) + } else { err }; + log::warn!("updatedb falló: {}", err); + vec![MsgServidor::IndiceActualizado { ok: false, mensaje: format!("Error updatedb: {}", err) }] + } + Err(e) => { + log::warn!("No se pudo ejecutar updatedb: {}", e); + vec![MsgServidor::IndiceActualizado { ok: false, mensaje: format!("No se pudo ejecutar updatedb: {}", e) }] + } + } + } + } +} + +// ─── Vigilante de estado ────────────────────────────────────────────────────── + +async fn vigilar_estado(tx: Arc>) { + let playlist_path = gradio_tmp("playlist4"); + let estado_path = gradio_tmp("estado.json"); + + let mut ultima_playlist = std::time::SystemTime::UNIX_EPOCH; + let mut ultimo_estado = std::time::SystemTime::UNIX_EPOCH; + + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + + // Monitorear playlist4 + if let Ok(meta) = fs::metadata(&playlist_path) { + if let Ok(modificado) = meta.modified() { + if modificado > ultima_playlist { + ultima_playlist = modificado; + let tracks = leer_playlist(); + if let Ok(json) = serde_json::to_string(&MsgServidor::PlaylistCambiada { tracks }) { + let _ = tx.send(json + "\n"); + } + } + } + } + + // Monitorear estado.json + if let Ok(meta) = fs::metadata(&estado_path) { + if let Ok(modificado) = meta.modified() { + if modificado > ultimo_estado { + ultimo_estado = modificado; + let estado = leer_estado(); + if let Ok(json) = serde_json::to_string(&MsgServidor::EstadoCambiado { estado }) { + let _ = tx.send(json + "\n"); + } + } + } + } + } +} + +// ─── Helpers de archivos ────────────────────────────────────────────────────── + +fn gradio_home() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/root")) + .join(".gradio/data") +} + +fn gradio_tmp(nombre: &str) -> PathBuf { + gradio_home().join("tmp").join(nombre) +} + +fn leer_f64(path: &Path) -> f64 { + fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0.0) +} + +// ─── Playlist ───────────────────────────────────────────────────────────────── + +fn leer_playlist() -> Vec { + let path = gradio_tmp("playlist4"); + let content = fs::read_to_string(&path).unwrap_or_default(); + content + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|line| { + let mut parts = line.splitn(2, '\t'); + let ruta = parts.next()?.trim().to_string(); + let duracion = parts.next().unwrap_or("").trim().to_string(); + let titulo = PathBuf::from(&ruta) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + Some(TrackRemoto { ruta, duracion, titulo }) + }) + .collect() +} + +fn agregar_al_playlist(ruta: &str, duracion: &str, al_inicio: bool) { + let path = gradio_tmp("playlist4"); + let linea = format!("{}\t{}\n", ruta, duracion); + if al_inicio { + let existente = fs::read_to_string(&path).unwrap_or_default(); + let _ = fs::write(&path, format!("{}{}", linea, existente)); + } else { + use std::io::Write; + if let Ok(mut f) = fs::OpenOptions::new().append(true).create(true).open(path) { + let _ = f.write_all(linea.as_bytes()); + } + } +} + +fn eliminar_de_playlist(indice: usize) { + let path = gradio_tmp("playlist4"); + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut lineas: Vec<&str> = content.lines().collect(); + if indice < lineas.len() { + lineas.remove(indice); + } + let nuevo = lineas.join("\n") + "\n"; + let _ = fs::write(path, nuevo); +} + +fn mover_en_playlist(indice: usize, arriba: bool) { + let path = gradio_tmp("playlist4"); + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut lineas: Vec = content.lines().map(|s| s.to_string()).collect(); + if arriba && indice > 0 { + lineas.swap(indice, indice - 1); + } else if !arriba && indice + 1 < lineas.len() { + lineas.swap(indice, indice + 1); + } + let nuevo = lineas.join("\n") + "\n"; + let _ = fs::write(path, nuevo); +} + +// ─── Estado del reproductor ─────────────────────────────────────────────────── + +fn leer_estado() -> EstadoRemoto { + // estado.json es escrito por radio-player cada vez que cambia + let path = gradio_tmp("estado.json"); + if let Ok(data) = fs::read_to_string(&path) { + if let Ok(estado) = serde_json::from_str::(&data) { + return estado; + } + } + // Fallback: construir desde archivos individuales + let upvol = leer_f64(&gradio_tmp("upvol")).clamp(0.0, 100.0) as u8; + let downvol = leer_f64(&gradio_tmp("downvol")).clamp(0.0, 100.0) as u8; + EstadoRemoto { upvol, downvol, ..Default::default() } +} + +fn ejecutar_comando_player(cmd: &str) { + // Los comandos se ejecutan escribiendo archivos de señal + let base = gradio_tmp(""); + match cmd { + "play" => { let _ = fs::write(base.join("cmd_play"), "1"); } + "pause" => { let _ = fs::write(base.join("cmd_pause"), "1"); } + "stop" => { let _ = fs::write(base.join("cmd_stop"), "1"); } + "stop_after" => { let _ = fs::write(base.join("cmd_stop_after"), "1"); } + "siguiente" => { let _ = fs::write(base.join("cmd_siguiente"), "1"); } + _ => {} + } +} + +// ─── Pautaje (.com) ─────────────────────────────────────────────────────────── + +fn directorio_pautaje(tipo_p: &str) -> PathBuf { + let carpeta = match tipo_p { + "eventos" => "eventos", + "eventos-espera" => "eventos-espera", + _ => "comerciales", + }; + gradio_home().join(carpeta) +} + +fn ruta_pautaje(tipo_p: &str, hora: u8, minuto: u8) -> PathBuf { + directorio_pautaje(tipo_p) + .join(hora.to_string()) + .join(format!("{}.com", minuto)) +} + +fn leer_pautaje(tipo_p: &str, hora: u8, minuto: u8) -> Vec { + let path = ruta_pautaje(tipo_p, hora, minuto); + let content = fs::read_to_string(&path).unwrap_or_default(); + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(4, '|').collect(); + if parts.len() < 4 { return None; } + Some(EntradaRemota { + ruta: parts[0].trim().to_string(), + dias: parts[1].trim().to_string(), + inicio: parts[2].trim().to_string(), + fin: parts[3].trim().trim_end_matches('\r').to_string(), + }) + }) + .collect() +} + +fn escribir_pautaje(tipo_p: &str, hora: u8, minuto: u8, entradas: &[EntradaRemota]) { + let path = ruta_pautaje(tipo_p, hora, minuto); + if let Some(padre) = path.parent() { + let _ = fs::create_dir_all(padre); + } + let contenido: String = entradas + .iter() + .map(|e| format!("{}|{}|{}|{}\n", e.ruta, e.dias, e.inicio, e.fin)) + .collect(); + let _ = fs::write(path, contenido); +} + +// ─── Parrilla (.mus) ────────────────────────────────────────────────────────── + +fn ruta_parrilla(dia: u8, hora: u8) -> PathBuf { + gradio_home() + .join("parrilla") + .join(dia.to_string()) + .join(format!("{}-{}.mus", hora, hora + 1)) +} + +fn leer_parrilla(dia: u8, hora: u8) -> Vec { + let path = ruta_parrilla(dia, hora); + let content = fs::read_to_string(&path).unwrap_or_default(); + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| l.trim_end_matches('\r').to_string()) + .collect() +} + +fn escribir_parrilla(dia: u8, hora: u8, items: &[String]) { + let path = ruta_parrilla(dia, hora); + if let Some(padre) = path.parent() { + let _ = fs::create_dir_all(padre); + } + let contenido: String = items.iter().map(|s| format!("{}\n", s)).collect(); + let _ = fs::write(path, contenido); +} + +// ─── Botonera ───────────────────────────────────────────────────────────────── + +fn ruta_botonera_remote() -> PathBuf { + gradio_home().join("botonera-remote.json") +} + +fn ruta_botonera_config() -> PathBuf { + gradio_home().join("botonera").join("config.json") +} + +fn leer_botonera() -> Vec { + // Preferir versión guardada desde el cliente remoto + let path_remote = ruta_botonera_remote(); + if let Ok(data) = fs::read_to_string(&path_remote) { + if let Ok(botones) = serde_json::from_str::>(&data) { + return botones; + } + } + // Leer desde config.json (formato de tabs del player) y aplanar + let path_config = ruta_botonera_config(); + if let Ok(data) = fs::read_to_string(&path_config) { + if let Ok(v) = serde_json::from_str::(&data) { + let mut resultado: Vec = Vec::new(); + if let Some(tabs) = v["tabs"].as_array() { + for tab in tabs { + if let Some(botones) = tab["botones"].as_array() { + for boton in botones { + if boton.is_null() { continue; } + let ruta = boton["ruta"].as_str().unwrap_or("").to_string(); + let nombre = boton["nombre"].as_str().unwrap_or("").to_string(); + if ruta.is_empty() { continue; } + let indice = resultado.len() as u8; + resultado.push(BotonRemoto { + indice, + etiqueta: nombre, + ruta, + color: String::new(), + }); + } + } + } + } + return resultado; + } + } + Vec::new() +} + +fn escribir_botonera(botones: &[BotonRemoto]) { + let path = ruta_botonera_remote(); + if let Ok(json) = serde_json::to_string_pretty(botones) { + let _ = fs::write(path, json); + } +} + +// ─── Directorio ─────────────────────────────────────────────────────────────── + +fn listar_directorio(ruta: &str) -> MsgServidor { + let path = Path::new(ruta); + if !path.is_dir() { + return MsgServidor::Error { + mensaje: format!("No es un directorio: {}", ruta), + }; + } + + let mut entradas: Vec = Vec::new(); + let Ok(dir) = fs::read_dir(path) else { + return MsgServidor::Error { + mensaje: format!("No se pudo leer: {}", ruta), + }; + }; + + let mut dirs: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + + for entry in dir.flatten() { + let nombre = entry.file_name().to_string_lossy().to_string(); + if nombre.starts_with('.') { continue; } + let ruta_hijo = entry.path().to_string_lossy().to_string(); + let es_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + let es_audio = if !es_dir { es_archivo_audio(&nombre) } else { false }; + let tiene_hijos = if es_dir { + fs::read_dir(&entry.path()).map(|mut d| d.next().is_some()).unwrap_or(false) + } else { false }; + + let e = EntradaDir { nombre, ruta: ruta_hijo, es_dir, es_audio, tiene_hijos }; + if es_dir { dirs.push(e); } else if es_audio { files.push(e); } + } + + dirs.sort_by(|a, b| a.nombre.to_lowercase().cmp(&b.nombre.to_lowercase())); + files.sort_by(|a, b| a.nombre.to_lowercase().cmp(&b.nombre.to_lowercase())); + entradas.extend(dirs); + entradas.extend(files); + + MsgServidor::DirectorioListado { ruta: ruta.to_string(), entradas } +} + +fn es_archivo_audio(nombre: &str) -> bool { + let lower = nombre.to_lowercase(); + matches!( + lower.rsplit('.').next().unwrap_or(""), + "mp3" | "wav" | "ogg" | "flac" | "aac" | "m4a" | "opus" | "wma" | "mp2" | "aiff" + ) +} + +// ─── Búsqueda ───────────────────────────────────────────────────────────────── + +async fn buscar_audio(consulta: &str, ruta_base: Option<&str>) -> Vec { + // 1. `locate` (rápido) — usa el índice propio (~/.gradio/data/locatedb) si existe, + // si no el índice del sistema (plocate/mlocate), exactamente igual que + // search_with_locate() en gr_buscador.rs: sin acotar por carpetas, solo + // se descartan rutas que ya no existen en disco (índice desactualizado). + if let Some(resultados) = buscar_con_locate(consulta, ruta_base).await { + if !resultados.is_empty() { + return resultados; + } + } + + // 2. Fallback si `locate` no está disponible: búsqueda recursiva en filesystem, + // acotada a las carpetas reales de la biblioteca (derivadas de la parrilla) + // para no recorrer TODO el home — backups viejos, carpetas de empaquetado + // con copias repetidas de los mismos audios, etc. + let roots = if ruta_base.is_none() { busqueda_roots() } else { Vec::new() }; + let bases: Vec = match ruta_base { + Some(b) => vec![PathBuf::from(b)], + None if !roots.is_empty() => roots, + None => vec![dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))], + }; + + let consulta_lower = consulta.to_lowercase(); + let mut resultados = Vec::new(); + for base in &bases { + buscar_recursivo(base, &consulta_lower, &mut resultados, 0); + } + let mut vistos: std::collections::HashSet = std::collections::HashSet::new(); + resultados.retain(|r| vistos.insert(r.ruta.clone())); + resultados.sort_by(|a, b| a.nombre.cmp(&b.nombre)); + resultados.truncate(500); + resultados +} + +/// Busca con el comando `locate` (índice propio si existe, si no el del sistema). +/// `None` si el binario `locate` no está disponible; `Some(vec![])` si no hubo hits. +async fn buscar_con_locate(consulta: &str, ruta_base: Option<&str>) -> Option> { + tokio::process::Command::new("locate").arg("--version").output().await.ok()?; + + let db_propia = gradio_home().join("locatedb"); + let mut cmd = tokio::process::Command::new("locate"); + cmd.arg("-i").arg("--"); + if db_propia.exists() { + cmd.arg("-d").arg(&db_propia); + } + cmd.arg(consulta); + + let out = cmd.output().await.ok()?; + let texto = String::from_utf8_lossy(&out.stdout); + let consulta_lower = consulta.to_lowercase(); + let mut vistos: std::collections::HashSet = std::collections::HashSet::new(); + let mut resultados: Vec = texto + .lines() + .filter(|l| { + let lower = l.to_lowercase(); + lower.contains(&consulta_lower) && es_archivo_audio(l) + }) + .filter(|l| Path::new(l).exists()) // descarta entradas del índice ya obsoletas + .filter(|l| ruta_base.map(|b| l.starts_with(b)).unwrap_or(true)) + .filter(|l| vistos.insert(l.to_string())) + .map(|l| { + let path = Path::new(l); + ResultadoBusqueda { + ruta: l.to_string(), + nombre: path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + duracion: String::new(), + } + }) + .collect(); + resultados.sort_by(|a, b| a.nombre.cmp(&b.nombre)); + resultados.truncate(500); + Some(resultados) +} + +/// Carpetas reales de la biblioteca de audio, derivadas de las rutas usadas +/// en la parrilla (`~/.gradio/data/parrilla/*/*.mus`). Si no hay parrilla +/// configurada aún, usa carpetas típicas por defecto. Mismo criterio que +/// `search_roots_fallback()` en gr_buscador.rs — deben mantenerse alineados. +fn busqueda_roots() -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let mut roots: Vec = Vec::new(); + + let parrilla_dir = gradio_home().join("parrilla"); + if parrilla_dir.exists() { + collect_mus_dirs(&parrilla_dir, &mut roots); + } + + if roots.is_empty() { + for candidate in &["Musica", "Music", "G Radio", "001 radio"] { + let p = home.join(candidate); + if p.exists() { roots.push(p); } + } + } + + roots.sort(); + roots.dedup(); + roots +} + +/// Extrae carpetas de origen referenciadas en archivos .mus de la parrilla. +fn collect_mus_dirs(dir: &Path, roots: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_mus_dirs(&path, roots); + } else if path.extension().and_then(|e| e.to_str()) == Some("mus") { + if let Ok(content) = fs::read_to_string(&path) { + for line in content.lines() { + let raw = line.trim() + .trim_end_matches('/') + .trim_end_matches('*') + .trim_end_matches('/'); + let p = PathBuf::from(raw); + if p.is_dir() { + roots.push(p); + } else if let Some(parent) = p.parent() { + if parent.exists() { + roots.push(parent.to_path_buf()); + } + } + } + } + } + } +} + +fn buscar_recursivo(dir: &Path, consulta: &str, resultados: &mut Vec, depth: u32) { + if depth > 8 { return; } + let Ok(entries) = fs::read_dir(dir) else { return; }; + for entry in entries.flatten() { + let nombre = entry.file_name().to_string_lossy().to_string(); + if nombre.starts_with('.') { continue; } + let path = entry.path(); + if path.is_dir() { + buscar_recursivo(&path, consulta, resultados, depth + 1); + } else if es_archivo_audio(&nombre) && nombre.to_lowercase().contains(consulta) { + resultados.push(ResultadoBusqueda { + ruta: path.to_string_lossy().to_string(), + nombre, + duracion: String::new(), // se obtiene con InfoAudio si se necesita + }); + } + } +} + +// ─── Duración de audio ──────────────────────────────────────────────────────── + +async fn obtener_duracion_audio(ruta: &str) -> String { + let output = tokio::process::Command::new("ffprobe") + .args([ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + ruta, + ]) + .output() + .await; + + match output { + Ok(o) if o.status.success() => { + let secs: f64 = String::from_utf8_lossy(&o.stdout) + .trim() + .parse() + .unwrap_or(0.0); + let s = secs as u64; + format!("{:02}:{:02}:{:05.3}", s / 3600, (s % 3600) / 60, secs % 60.0) + } + _ => String::new(), + } +} + +// ─── Reportes ───────────────────────────────────────────────────────────────── + +fn generar_reporte(tipo_r: &str, fecha: &str) -> Vec { + match tipo_r { + "playlist" => { + leer_playlist() + .into_iter() + .enumerate() + .map(|(i, t)| format!("{:3}. {} [{}]", i + 1, t.titulo, t.duracion)) + .collect() + } + "parrilla" => generar_reporte_parrilla(fecha), + "comerciales" => listar_pautaje_activo("comerciales", fecha), + "eventos" => listar_pautaje_activo("eventos", fecha), + "emitidos" => leer_log_emitidos(fecha), + _ => vec![format!("Tipo de reporte desconocido: {}", tipo_r)], + } +} + +fn generar_reporte_parrilla(fecha: &str) -> Vec { + let dia = match NaiveDate::parse_from_str(fecha, "%Y%m%d") { + Ok(d) => d.weekday().number_from_monday() as u8, // 1=Lun..7=Dom + Err(_) => return vec![format!("Fecha inválida: {}", fecha)], + }; + let nombre_dia = match dia { + 1 => "Lunes", 2 => "Martes", 3 => "Miércoles", + 4 => "Jueves", 5 => "Viernes", 6 => "Sábado", _ => "Domingo", + }; + let mut lineas = Vec::new(); + lineas.push(format!("Parrilla — {} ({})", nombre_dia, fecha)); + let mut tiene_datos = false; + for hora in 0u8..24 { + let items = leer_parrilla(dia, hora); + if !items.is_empty() { + tiene_datos = true; + lineas.push(format!("── {:02}:00–{:02}:00 ({} items)", hora, hora + 1, items.len())); + for item in items { + let etiqueta = if item.ends_with("/*") { + format!(" [carpeta] {}", item.trim_end_matches("/*")) + } else { + format!(" {}", item.rsplit('/').next().unwrap_or(&item)) + }; + lineas.push(etiqueta); + } + } + } + if !tiene_datos { + lineas.push(format!("Sin parrilla programada para el {}.", nombre_dia)); + } + lineas +} + +fn listar_pautaje_activo(tipo_p: &str, _fecha: &str) -> Vec { + let mut lineas = Vec::new(); + for hora in 0u8..24 { + for minuto in [0u8, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] { + let entradas = leer_pautaje(tipo_p, hora, minuto); + if !entradas.is_empty() { + lineas.push(format!("── {:02}:{:02} ({} entradas)", hora, minuto, entradas.len())); + for e in entradas { + lineas.push(format!(" {} | {} | {} - {}", e.ruta, e.dias, e.inicio, e.fin)); + } + } + } + } + if lineas.is_empty() { + lineas.push(format!("No hay pautaje de {} programado.", tipo_p)); + } + lineas +} + +fn leer_log_emitidos(fecha: &str) -> Vec { + // Buscar log de emisión en ~/.gradio/logs/{fecha}.log + let path = gradio_home().join("logs").join(format!("{}.log", fecha)); + if let Ok(content) = fs::read_to_string(&path) { + return content.lines().map(|l| l.to_string()).collect(); + } + vec![format!("No hay log para la fecha {}.", fecha)] +} + +// ─── Inicialización desde main.rs ──────────────────────────────────────────── + +/// Escribe el estado actual del reproductor en estado.json. +/// Llamado por el timer de build_ui cada 2 segundos. +pub fn escribir_estado_json(estado: &EstadoRemoto) { + let path = gradio_tmp("estado.json"); + if let Some(padre) = path.parent() { + let _ = fs::create_dir_all(padre); + } + if let Ok(json) = serde_json::to_string(estado) { + let _ = fs::write(path, json); + } +} + +// ─── Colas activas ──────────────────────────────────────────────────────────── + +fn ruta_cola(tipo: &str) -> PathBuf { + let nombre = match tipo { + "eventos" => "eventoslist", + "eventos_espera" => "eventos-esperalist", + _ => "comercialeslist4", + }; + gradio_home().join("tmp").join(nombre) +} + +fn leer_cola(tipo: &str) -> Vec { + fs::read_to_string(ruta_cola(tipo)) + .unwrap_or_default() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.trim().to_string()) + .collect() +} + +fn eliminar_de_cola(tipo: &str, indice: usize) { + let path = ruta_cola(tipo); + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut lineas: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); + if indice < lineas.len() { + lineas.remove(indice); + } + let nuevo = if lineas.is_empty() { + String::new() + } else { + lineas.join("\n") + "\n" + }; + let _ = fs::write(path, nuevo); +} + +// ─── Próximas tandas de comerciales (igual que el panel del vivo) ───────────── + +/// Reutiliza el escaneo de horario de main.rs (mismo criterio: hasta 4 tandas +/// futuras con comerciales, respetando played_breaks, máscara de días y rango +/// de fechas) para exponerlo al cliente remoto. +fn obtener_proximas_tandas() -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + crate::scan_upcoming_breaks(&home, 4) + .into_iter() + .map(|br| TandaRemota { + hora: br.hour, + minuto: br.minute, + items: br.items.into_iter().map(|(ruta, nombre, duracion_secs, _file_line)| { + ItemTanda { ruta, nombre, duracion_secs } + }).collect(), + duracion_total_secs: br.total_dur, + }) + .collect() +} + +/// Reproduce una tanda concreta: reemplaza la cola activa de comerciales con +/// los ítems recibidos (los mismos que se le mostraron al cliente), la marca +/// como reproducida en played_breaks para que el scheduler no la repita, y +/// dispara la señal IPC que el loop de radio-player usa para arrancarla +/// (idéntico al botón ▶ de "Próximas tandas" en el vivo). +fn reproducir_tanda(hora: u32, minuto: u32, items: &[String]) { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let comlist = home.join(".gradio/data/tmp/comercialeslist4"); + if let Ok(mut f) = fs::OpenOptions::new().write(true).create(true).truncate(true).open(&comlist) { + use std::io::Write; + for path in items { + let _ = writeln!(f, "{}", path); + } + } + crate::mark_break_played(hora, minuto, &home); + let _ = fs::write(home.join(".gradio/data/tmp/cmd_play_tanda"), "1"); +} + +/// Lanza el servidor en un hilo separado con su propio runtime tokio. +/// Se llama desde `main()` de radio-player antes de `app.run()`. +pub fn iniciar(puerto: u16, token: String, cliente_conectado: ClienteConectado) { + std::thread::Builder::new() + .name("gr-client-server".to_string()) + .spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("gr-client: no se pudo crear runtime tokio"); + rt.block_on(async move { + let srv = Servidor::nuevo(puerto, token, cliente_conectado); + if let Err(e) = srv.ejecutar().await { + log::error!("gr-client servidor: {}", e); + } + }); + }) + .expect("gr-client: no se pudo crear hilo servidor"); +} diff --git a/src/skin.rs b/src/skin.rs new file mode 100644 index 0000000..9846549 --- /dev/null +++ b/src/skin.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Sistema de skins: permite reemplazar iconos, CSS y fondo de ventana +//! mediante carpetas en `~/.gradio/data/skins//`, sin tocar el +//! binario. El skin activo se guarda en `~/.gradio/data/tmp/skin`. + +use std::fs; +use std::path::PathBuf; + +fn home() -> Option { + dirs::home_dir() +} + +fn archivo_skin_activo() -> Option { + home().map(|h| h.join(".gradio/data/tmp/skin")) +} + +fn dir_skins() -> Option { + home().map(|h| h.join(".gradio/data/skins")) +} + +/// Nombre del skin activo, o `None` si se usa el skin por defecto (embebido). +pub fn nombre_activo() -> Option { + let path = archivo_skin_activo()?; + let texto = fs::read_to_string(path).ok()?; + let nombre = texto.trim(); + if nombre.is_empty() { None } else { Some(nombre.to_string()) } +} + +/// Carpeta del skin activo, si existe en disco. +pub fn dir_activo() -> Option { + let nombre = nombre_activo()?; + let dir = dir_skins()?.join(nombre); + if dir.is_dir() { Some(dir) } else { None } +} + +/// Devuelve los bytes del icono `archivo` (ej. "play-on.png") tomados del +/// skin activo si existe, o `fallback` (el recurso embebido) en caso +/// contrario. +pub fn icono(archivo: &str, fallback: &'static [u8]) -> Vec { + if let Some(dir) = dir_activo() { + let ruta = dir.join("iconos").join(archivo); + if let Ok(bytes) = fs::read(&ruta) { + return bytes; + } + } + fallback.to_vec() +} + +/// Lista los nombres de los skins instalados en `~/.gradio/data/skins/`. +pub fn listar() -> Vec { + let Some(dir) = dir_skins() else { return Vec::new(); }; + let Ok(entradas) = fs::read_dir(&dir) else { return Vec::new(); }; + let mut nombres: Vec = entradas + .flatten() + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + nombres.sort(); + nombres +} + +/// CSS adicional aportado por el skin activo: su `estilo.css` (si existe) más +/// una regla de fondo generada a partir de `fondo.png`/`fondo.jpg` (si +/// existe). `None` si el skin activo no aporta nada. +pub fn css_extra() -> Option { + let dir = dir_activo()?; + let mut css = String::new(); + + if let Ok(texto) = fs::read_to_string(dir.join("estilo.css")) { + css.push_str(&texto); + css.push('\n'); + } + + for nombre_fondo in ["fondo.png", "fondo.jpg", "fondo.jpeg"] { + let ruta = dir.join(nombre_fondo); + if ruta.is_file() { + if let Some(ruta_str) = ruta.to_str() { + css.push_str(&format!( + "window {{ background-image: url(\"file://{}\"); background-size: cover; background-position: center; background-repeat: no-repeat; }}\n", + ruta_str + )); + } + break; + } + } + + if css.trim().is_empty() { None } else { Some(css) } +} + +/// Aplica el CSS adicional del skin activo (si lo hay) al `Display` dado, +/// con prioridad superior a cualquier CSS base ya existente en la app. +pub fn aplicar_css_extra(display: &gdk4::Display) { + let Some(css) = css_extra() else { return; }; + let provider = gtk4::CssProvider::new(); + provider.load_from_data(&css); + gtk4::style_context_add_provider_for_display( + display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_USER + 100, + ); +} + +/// Crea `~/.gradio/data/skins/` si no existe. +pub fn crear_dir_skins_si_falta() { + if let Some(dir) = dir_skins() { + let _ = fs::create_dir_all(dir); + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..0271a0e --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,146 @@ +// storage.rs — Lectura y escritura de archivos .com +// +// Estructura en disco: +// $HOME/.gradio/data/comerciales/{hora}/{minuto}.com +// $HOME/.gradio/data/eventos/{hora}/{minuto}.com +// $HOME/.gradio/data/eventos-espera/{hora}/{minuto}.com +// +// Donde hora ∈ [0,23] y minuto ∈ {0,5,10,…,55} + +use std::fs; +use std::path::{Path, PathBuf}; +use chrono::NaiveDate; + +use crate::models::{EntradaPautaje, TipoPautaje}; + +// ─── Rutas ───────────────────────────────────────────────────────────────── + +pub fn directorio_base() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".gradio").join("data") +} + +pub fn ruta_archivo(tipo: &TipoPautaje, hora: u8, minuto: u8) -> PathBuf { + directorio_base() + .join(tipo.carpeta()) + .join(hora.to_string()) + .join(format!("{}.com", minuto)) +} + +/// Crea todos los directorios necesarios (se llama una vez al arranque) +pub fn crear_directorios() { + let base = directorio_base(); + for tipo in &[ + TipoPautaje::Comerciales, + TipoPautaje::Eventos, + TipoPautaje::EventosEnEspera, + ] { + for hora in 0u8..24 { + let _ = fs::create_dir_all(base.join(tipo.carpeta()).join(hora.to_string())); + } + } +} + +// ─── Lectura ─────────────────────────────────────────────────────────────── + +pub fn leer_entradas(tipo: &TipoPautaje, hora: u8, minuto: u8) -> Vec { + leer_desde(&ruta_archivo(tipo, hora, minuto)) +} + +fn leer_desde(ruta: &Path) -> Vec { + match fs::read_to_string(ruta) { + Ok(contenido) => contenido + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(EntradaPautaje::parsear) + .collect(), + Err(_) => Vec::new(), + } +} + +// ─── Escritura ───────────────────────────────────────────────────────────── + +pub fn escribir_entradas(tipo: &TipoPautaje, hora: u8, minuto: u8, entradas: &[EntradaPautaje]) { + let ruta = ruta_archivo(tipo, hora, minuto); + if let Some(padre) = ruta.parent() { + let _ = fs::create_dir_all(padre); + } + let contenido: String = entradas.iter().map(|e| e.serializar()).collect(); + let _ = fs::write(&ruta, contenido); +} + +// ─── Operaciones de alto nivel ───────────────────────────────────────────── + +/// Agrega una entrada al corte (sin duplicar por contenido idéntico) +pub fn agregar_entrada(tipo: &TipoPautaje, hora: u8, minuto: u8, entrada: EntradaPautaje) { + let mut entradas = leer_entradas(tipo, hora, minuto); + if !entradas.iter().any(|e| e.serializar() == entrada.serializar()) { + entradas.push(entrada); + escribir_entradas(tipo, hora, minuto, &entradas); + } +} + +/// Agrega una entrada siempre, sin verificar duplicados (usado para "Hora") +pub fn agregar_entrada_siempre(tipo: &TipoPautaje, hora: u8, minuto: u8, entrada: EntradaPautaje) { + let mut entradas = leer_entradas(tipo, hora, minuto); + entradas.push(entrada); + escribir_entradas(tipo, hora, minuto, &entradas); +} + +/// Mueve una entrada hacia arriba (subir=true) o hacia abajo (subir=false) +pub fn mover_entrada(tipo: &TipoPautaje, hora: u8, minuto: u8, indice: usize, subir: bool) { + let mut entradas = leer_entradas(tipo, hora, minuto); + if subir && indice > 0 { + entradas.swap(indice, indice - 1); + } else if !subir && indice + 1 < entradas.len() { + entradas.swap(indice, indice + 1); + } else { + return; // sin cambios + } + escribir_entradas(tipo, hora, minuto, &entradas); +} + +/// Elimina la entrada en el índice dado +pub fn eliminar_entrada(tipo: &TipoPautaje, hora: u8, minuto: u8, indice: usize) { + let mut entradas = leer_entradas(tipo, hora, minuto); + if indice < entradas.len() { + entradas.remove(indice); + escribir_entradas(tipo, hora, minuto, &entradas); + } +} + +/// Elimina de todos los archivos .com las entradas cuya fecha de fin < hoy. +/// Retorna el número total de entradas eliminadas. +pub fn limpiar_caducados(hoy: NaiveDate) -> usize { + let base = directorio_base(); + let mut total = 0usize; + + for tipo in &[ + TipoPautaje::Comerciales, + TipoPautaje::Eventos, + TipoPautaje::EventosEnEspera, + ] { + for hora in 0u8..24 { + for minuto in (0u8..60).step_by(5) { + let ruta = base + .join(tipo.carpeta()) + .join(hora.to_string()) + .join(format!("{}.com", minuto)); + if !ruta.exists() { continue; } + + let antes = leer_desde(&ruta); + let n_antes = antes.len(); + let despues: Vec<_> = antes.into_iter().filter(|e| e.fin >= hoy).collect(); + let n_despues = despues.len(); + + if n_antes != n_despues { + total += n_antes - n_despues; + let contenido: String = despues.iter().map(|e| e.serializar()).collect(); + let _ = fs::write(&ruta, contenido); + } + } + } + } + total +} diff --git a/src/storage_botonera.rs b/src/storage_botonera.rs new file mode 100644 index 0000000..1eeef23 --- /dev/null +++ b/src/storage_botonera.rs @@ -0,0 +1,208 @@ +// storage_botonera.rs — Persistencia de la botonera de efectos +// +// Estructura en disco: +// $HOME/.gradio/data/botonera/config.json (nombres de pestañas y botones) +// +// Formato JSON: +// { +// "tabs": [ +// { "nombre": "FX1", "botones": [ +// { "ruta": "/ruta/audio.mp3", "nombre": "PISADOR01" }, +// null, ... +// ]} +// ] +// } +// +// Cada pestaña tiene FILAS x COLS botones (algunos pueden ser null/vacíos) + +use std::fs; +use std::path::PathBuf; + +pub const FILAS: usize = 8; +pub const COLS: usize = 5; +pub const NUM_TABS: usize = 5; +pub const NOMBRES_TABS_DEFAULT: [&str; 5] = ["FX1", "FX2", "FX3", "FX4", "FX5"]; + +#[derive(Debug, Clone)] +pub struct Boton { + pub ruta: String, + pub nombre: String, // nombre display (sin extensión por defecto) +} + +#[derive(Debug, Clone)] +pub struct TabBotonera { + pub nombre: String, + pub botones: Vec>, // FILAS * COLS elementos +} + +impl TabBotonera { + pub fn nuevo(nombre: &str) -> Self { + TabBotonera { + nombre: nombre.to_string(), + botones: vec![None; FILAS * COLS], + } + } + pub fn idx(fila: usize, col: usize) -> usize { fila * COLS + col } +} + +// ─── Rutas ───────────────────────────────────────────────────────────────── + +fn ruta_config() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".gradio").join("data").join("botonera").join("config.json") +} + +pub fn crear_directorios() { + if let Some(dir) = ruta_config().parent() { + let _ = fs::create_dir_all(dir); + } +} + +// ─── Serialización manual (sin serde para mantener dependencias mínimas) ─── + +pub fn cargar() -> Vec { + let ruta = ruta_config(); + if !ruta.exists() { + return NOMBRES_TABS_DEFAULT.iter() + .map(|n| TabBotonera::nuevo(n)) + .collect(); + } + match fs::read_to_string(&ruta) { + Ok(contenido) => parsear_json(&contenido), + Err(_) => NOMBRES_TABS_DEFAULT.iter() + .map(|n| TabBotonera::nuevo(n)) + .collect(), + } +} + +pub fn guardar(tabs: &[TabBotonera]) { + let ruta = ruta_config(); + if let Some(dir) = ruta.parent() { + let _ = fs::create_dir_all(dir); + } + let json = serializar_json(tabs); + let _ = fs::write(&ruta, json); +} + +fn serializar_json(tabs: &[TabBotonera]) -> String { + let mut s = String::from("{\n \"tabs\": [\n"); + for (ti, tab) in tabs.iter().enumerate() { + s.push_str(&format!(" {{\"nombre\": {}, \"botones\": [\n", + json_str(&tab.nombre))); + for (bi, boton) in tab.botones.iter().enumerate() { + match boton { + None => s.push_str(" null"), + Some(b) => s.push_str(&format!(" {{\"ruta\": {}, \"nombre\": {}}}", + json_str(&b.ruta), json_str(&b.nombre))), + } + if bi + 1 < tab.botones.len() { s.push_str(",\n"); } else { s.push('\n'); } + } + s.push_str(" ]}"); + if ti + 1 < tabs.len() { s.push_str(",\n"); } else { s.push('\n'); } + } + s.push_str(" ]\n}"); + s +} + +fn json_str(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")) +} + +fn parsear_json(json: &str) -> Vec { + // Parser minimalista — suficiente para nuestro formato conocido + let mut tabs: Vec = Vec::new(); + // Buscar objetos de tab: {"nombre": "...", "botones": [...]} + let mut pos = 0; + let _bytes = json.as_bytes(); + + while let Some(tab_start) = find_str(json, "{\"nombre\":", pos) { + // Extraer nombre del tab + let nombre = extract_string_after(json, "\"nombre\":", tab_start) + .unwrap_or_else(|| "FX".to_string()); + // Extraer array de botones + let arr_start = find_str(json, "\"botones\":", tab_start) + .and_then(|p| find_char(json, '[', p)) + .unwrap_or(tab_start); + + let mut tab = TabBotonera::nuevo(&nombre); + let mut bpos = arr_start + 1; + let mut bi = 0; + + while bi < FILAS * COLS { + // Saltar espacios y comas + while bpos < json.len() && (json.as_bytes()[bpos] == b' ' || + json.as_bytes()[bpos] == b'\n' || json.as_bytes()[bpos] == b',' || + json.as_bytes()[bpos] == b'\r') { + bpos += 1; + } + if bpos >= json.len() { break; } + if json.as_bytes()[bpos] == b']' { break; } + + if json[bpos..].starts_with("null") { + tab.botones[bi] = None; + bpos += 4; + } else if json.as_bytes()[bpos] == b'{' { + let ruta = extract_string_after(json, "\"ruta\":", bpos) + .unwrap_or_default(); + let nombre_b = extract_string_after(json, "\"nombre\":", bpos) + .unwrap_or_else(|| nombre_desde_ruta(&ruta)); + tab.botones[bi] = Some(Boton { ruta, nombre: nombre_b }); + // Avanzar hasta el } de cierre + if let Some(end) = find_char(json, '}', bpos) { + bpos = end + 1; + } else { break; } + } else { + bpos += 1; + } + bi += 1; + } + tabs.push(tab); + pos = tab_start + 1; + if tabs.len() >= NUM_TABS { break; } + } + + // Completar tabs faltantes + while tabs.len() < NUM_TABS { + tabs.push(TabBotonera::nuevo(NOMBRES_TABS_DEFAULT[tabs.len()])); + } + tabs +} + +fn find_str(s: &str, pat: &str, from: usize) -> Option { + s[from..].find(pat).map(|p| p + from) +} +fn find_char(s: &str, ch: char, from: usize) -> Option { + s[from..].find(ch).map(|p| p + from) +} +fn extract_string_after(s: &str, key: &str, from: usize) -> Option { + let key_pos = find_str(s, key, from)?; + let after = &s[key_pos + key.len()..]; + let q1 = after.find('"')? + 1; + let rest = &after[q1..]; + let mut result = String::new(); + let mut chars = rest.chars(); + while let Some(c) = chars.next() { + if c == '"' { break; } + if c == '\\' { + match chars.next() { + Some('"') => result.push('"'), + Some('\\') => result.push('\\'), + Some('n') => result.push('\n'), + Some(other) => { result.push('\\'); result.push(other); } + None => break, + } + } else { + result.push(c); + } + } + Some(result) +} + +pub fn nombre_desde_ruta(ruta: &str) -> String { + std::path::Path::new(ruta) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(ruta) + .to_string() +} diff --git a/src/storage_parrilla.rs b/src/storage_parrilla.rs new file mode 100644 index 0000000..4e2da8e --- /dev/null +++ b/src/storage_parrilla.rs @@ -0,0 +1,142 @@ +// storage_parrilla.rs — Lectura y escritura de archivos .mus de la parrilla musical +// +// Estructura en disco: +// $HOME/.gradio/data/parrilla/{dia}/{hora}-{hora+1}.mus +// +// dia: 1=Lunes … 7=Domingo +// hora: 0..23 → archivo "0-1.mus", "1-2.mus", …, "23-24.mus" +// +// Formato del archivo: una línea por ítem. +// Cada línea es una ruta (archivo, directorio con /* al final, o URL) +// o la palabra especial "Hora" que el reproductor interpreta como inserción de hora. + +use std::fs; +use std::path::PathBuf; + +// ─── Rutas ───────────────────────────────────────────────────────────────── + +pub fn directorio_base() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".gradio").join("data").join("parrilla") +} + +/// Nombre del archivo para un día y hora dados +/// dia: 1-7, hora: 0-23 +/// Formato: "{hora}-{hora+1}.mus" → "0-1.mus", "1-2.mus", …, "23-24.mus" +pub fn nombre_archivo(hora: u8) -> String { + let siguiente = hora + 1; // No usar módulo — el sistema original usa 23-24.mus + format!("{}-{}.mus", hora, siguiente) +} + +pub fn ruta_archivo(dia: u8, hora: u8) -> PathBuf { + directorio_base() + .join(dia.to_string()) + .join(nombre_archivo(hora)) +} + +/// Crea todos los directorios necesarios (se llama al arranque) +pub fn crear_directorios() { + let base = directorio_base(); + for dia in 1u8..=7 { + let _ = fs::create_dir_all(base.join(dia.to_string())); + } +} + +// ─── Lectura ─────────────────────────────────────────────────────────────── + +/// Lee las líneas de un archivo .mus (puede incluir "Hora" como ítem especial) +pub fn leer_items(dia: u8, hora: u8) -> Vec { + let ruta = ruta_archivo(dia, hora); + match fs::read_to_string(&ruta) { + Ok(contenido) => contenido + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(), + Err(_) => Vec::new(), + } +} + +// ─── Escritura ───────────────────────────────────────────────────────────── + +/// Escribe los ítems al archivo .mus +pub fn escribir_items(dia: u8, hora: u8, items: &[String]) { + let ruta = ruta_archivo(dia, hora); + if let Some(padre) = ruta.parent() { + let _ = fs::create_dir_all(padre); + } + let contenido = items.join("\n") + "\n"; + let _ = fs::write(&ruta, contenido); +} + +// ─── Operaciones de edición ──────────────────────────────────────────────── + +pub fn agregar_item(dia: u8, hora: u8, item: String) { + let mut items = leer_items(dia, hora); + items.push(item); + escribir_items(dia, hora, &items); +} + +pub fn mover_item(dia: u8, hora: u8, indice: usize, subir: bool) { + let mut items = leer_items(dia, hora); + if subir && indice > 0 { + items.swap(indice, indice - 1); + } else if !subir && indice + 1 < items.len() { + items.swap(indice, indice + 1); + } else { + return; + } + escribir_items(dia, hora, &items); +} + +pub fn eliminar_item(dia: u8, hora: u8, indice: usize) { + let mut items = leer_items(dia, hora); + if indice < items.len() { + items.remove(indice); + escribir_items(dia, hora, &items); + } +} + +// ─── Pisadores por hora ──────────────────────────────────────────────────── + +/// Ruta del archivo de carpeta de pisadores para un día y hora +pub fn ruta_pisador(dia: u8, hora: u8) -> PathBuf { + directorio_base() + .join(dia.to_string()) + .join(format!("{}-{}.pisador", hora, hora + 1)) +} + +/// Lee la carpeta de pisadores específica para esta hora (o "" si no está configurada) +pub fn leer_pisador_dir(dia: u8, hora: u8) -> String { + fs::read_to_string(ruta_pisador(dia, hora)) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Guarda la carpeta de pisadores para esta hora (borra el archivo si dir está vacío) +pub fn escribir_pisador_dir(dia: u8, hora: u8, dir: &str) { + let ruta = ruta_pisador(dia, hora); + if dir.trim().is_empty() { + let _ = fs::remove_file(&ruta); + } else { + let _ = fs::write(&ruta, dir.trim()); + } +} + +// ─── Operaciones de copia ────────────────────────────────────────────────── + +/// Copia la parrilla de (dia_origen, hora_origen) a (dia_destino, hora_destino) +pub fn copiar_hora(dia_orig: u8, hora_orig: u8, dia_dest: u8, hora_dest: u8) { + let items = leer_items(dia_orig, hora_orig); + escribir_items(dia_dest, hora_dest, &items); + let pis = leer_pisador_dir(dia_orig, hora_orig); + escribir_pisador_dir(dia_dest, hora_dest, &pis); +} + +/// Copia todas las horas de dia_origen a dia_destino (incluye carpetas de pisadores) +pub fn copiar_dia(dia_orig: u8, dia_dest: u8) { + for hora in 0u8..24 { + copiar_hora(dia_orig, hora, dia_dest, hora); + } +} diff --git a/src/ui/barra_herramientas.rs b/src/ui/barra_herramientas.rs new file mode 100644 index 0000000..7540d8c --- /dev/null +++ b/src/ui/barra_herramientas.rs @@ -0,0 +1,11 @@ +// ui/barra_herramientas.rs — Helpers de formato para la barra de herramientas + +use chrono::NaiveDate; + +pub fn formato_fecha_display(fecha: &NaiveDate) -> String { + fecha.format("%d/%m/%Y").to_string() +} + +pub fn parsear_fecha(texto: &str) -> Option { + NaiveDate::parse_from_str(texto, "%d/%m/%Y").ok() +} diff --git a/src/ui/botonera/mod.rs b/src/ui/botonera/mod.rs new file mode 100644 index 0000000..72e3d3f --- /dev/null +++ b/src/ui/botonera/mod.rs @@ -0,0 +1 @@ +pub mod ventana_botonera; diff --git a/src/ui/botonera/ventana_botonera.rs b/src/ui/botonera/ventana_botonera.rs new file mode 100644 index 0000000..5052318 --- /dev/null +++ b/src/ui/botonera/ventana_botonera.rs @@ -0,0 +1,739 @@ +// ui/botonera/ventana_botonera.rs — GR Botonera de Efectos +// +// 5 pestañas renombrables con doble clic +// FILAS x COLS botones por pestaña +// Drag & drop de audios +// Clic: reproducir / detener con mpv +// Paleta de colores con contraste garantizado + +use gtk4 as gtk; +use gtk::prelude::*; +use gtk::{ + Application, ApplicationWindow, Box as GtkBox, Button, Entry, + Label, Notebook, Orientation, ScrolledWindow, Grid, + DropTarget, GestureClick, PopoverMenu, + FileChooserDialog, FileFilter, +}; +use gtk::gdk; +use glib; +use std::cell::RefCell; +use std::rc::Rc; +use gstreamer as gst; +use gst::prelude::*; + +use crate::storage_botonera::{ + self, Boton, TabBotonera, FILAS, COLS, NUM_TABS, nombre_desde_ruta, +}; +use crate::ui::comun; +use crate::skin; + +const ICONO_BOTONERA: &[u8] = include_bytes!("../../../assets/botonera48x48.png"); + +// ─── Paleta de colores ───────────────────────────────────────────────────── + +const PALETA: &[&str] = &[ + "#1565c0", "#2e7d32", "#6a1b9a", "#c62828", "#e65100", + "#00695c", "#ad1457", "#4527a0", "#558b2f", "#00838f", + "#f57f17", "#283593", "#37474f", "#4e342e", "#00600f", +]; + +fn color_texto(hex_bg: &str) -> &'static str { + let hex = hex_bg.trim_start_matches('#'); + if hex.len() < 6 { return "white"; } + let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f32; + let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f32; + let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f32; + let lum = 0.2126*(r/255.0) + 0.7152*(g/255.0) + 0.0722*(b/255.0); + if lum > 0.45 { "black" } else { "white" } +} + +// ─── Estado ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +struct Reproduciendo { + tab: usize, fila: usize, col: usize, +} + +struct Estado { + reproduciendo: Option, + css_provider: gtk::CssProvider, + pipeline: Option, +} + +impl Estado { + fn nuevo() -> Self { + Estado { + reproduciendo: None, + css_provider: gtk::CssProvider::new(), + pipeline: None, + } + } +} + +fn path_to_uri(path: &str) -> String { + #[cfg(target_os = "windows")] + { + let fwd = path.replace('\\', "/"); + let encoded: String = fwd.bytes().map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => (b as char).to_string(), + _ => format!("%{:02X}", b), + }).collect(); + if encoded.len() >= 2 && encoded.as_bytes()[1] == b':' { + return format!("file:///{}", encoded); + } + return format!("file://{}", encoded); + } + #[cfg(not(target_os = "windows"))] + { + let encoded: String = path.chars().map(|c| match c { + ' ' => "%20".to_string(), + '#' => "%23".to_string(), + '?' => "%3F".to_string(), + '%' => "%25".to_string(), + _ => c.to_string(), + }).collect(); + format!("file://{}", encoded) + } +} + +// ─── Ventana ─────────────────────────────────────────────────────────────── + +pub fn construir_ventana_botonera(app: &Application) { + storage_botonera::crear_directorios(); + + let tabs_data = Rc::new(RefCell::new(storage_botonera::cargar())); + let estado = Rc::new(RefCell::new(Estado::nuevo())); + + let ventana = ApplicationWindow::builder() + .application(app) + .title("GR_Botonera") + .default_width(860) + .default_height(640) + .build(); + + // CSS base + let css_base = gtk::CssProvider::new(); + css_base.load_from_data(concat!( + "button.fx-btn { border-radius:4px; border:1px solid rgba(255,255,255,0.2);", + " font-size:10pt; font-weight:bold; min-height:60px; }", + "button.fx-vacio { background:#2a2a2a; color:#555;", + " border:1px dashed #444; border-radius:4px; min-height:60px; }", + "button.fx-vacio:hover { background:#333; border-color:#666; }", + "button.fx-play { border:3px solid #ffffff !important;", + " border-radius:4px; min-height:60px; font-weight:bold; }", + "label.tab-label { font-weight:bold; padding:2px 8px; }", + )); + gtk::style_context_add_provider_for_display( + >k4::prelude::WidgetExt::display(&ventana), + &css_base, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + // Provider dinámico para colores de botones (se actualiza al cambiar estado) + { + let e = estado.borrow(); + gtk::style_context_add_provider_for_display( + >k4::prelude::WidgetExt::display(&ventana), + &e.css_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1, + ); + } + skin::aplicar_css_extra(>k4::prelude::WidgetExt::display(&ventana)); + + let vbox = GtkBox::new(Orientation::Vertical, 0); + + let notebook = Notebook::new(); + notebook.set_vexpand(true); + + for tab_idx in 0..NUM_TABS { + let (tab_widget, tab_label) = construir_tab( + tab_idx, tabs_data.clone(), estado.clone(), + ); + notebook.append_page(&tab_widget, Some(&tab_label)); + } + + vbox.append(¬ebook); + ventana.set_child(Some(&vbox)); + + // Render inicial + actualizar_css_colores(0, &tabs_data, &estado); + + comun::aplicar_icono_ventana(&ventana, &skin::icono("botonera48x48.png", ICONO_BOTONERA)); + comun::aplicar_tamanio_ventana(&ventana, 900, 680); + ventana.present(); +} + +// ─── Tab ─────────────────────────────────────────────────────────────────── + +fn construir_tab( + tab_idx: usize, + tabs_data: Rc>>, + estado: Rc>, +) -> (gtk::Widget, gtk::Widget) { + let scroll = ScrolledWindow::new(); + scroll.set_hexpand(true); + scroll.set_vexpand(true); + + let grid = Grid::new(); + grid.set_column_spacing(4); + grid.set_row_spacing(4); + grid.set_column_homogeneous(true); + grid.set_row_homogeneous(true); + grid.set_margin_top(8); + grid.set_margin_bottom(8); + grid.set_margin_start(8); + grid.set_margin_end(8); + + let btns: Rc> = Rc::new( + (0..FILAS * COLS).map(|_| Button::new()).collect() + ); + + // Poblar grid y aplicar CSS inicial + poblar_botones(tab_idx, &btns, &grid, &tabs_data, &estado); + + // Conectar botones + for fila in 0..FILAS { + for col in 0..COLS { + let idx = TabBotonera::idx(fila, col); + let btn = &btns[idx]; + + // Clic izquierdo → reproducir/detener + { + let td = tabs_data.clone(); + let est = estado.clone(); + let btns_c = btns.clone(); + let grid_c = grid.clone(); + btn.connect_clicked(move |_| { + manejar_clic(tab_idx, fila, col, &td, &est, &btns_c, &grid_c); + }); + } + + // Clic derecho → menú contextual + { + let td = tabs_data.clone(); + let est = estado.clone(); + let btns_c = btns.clone(); + let grid_c = grid.clone(); + let btn_c = btn.clone(); + + let gesture_right = GestureClick::new(); + gesture_right.set_button(3); // botón derecho + gesture_right.connect_released(move |_, _, x, y| { + let tiene_audio = td.borrow()[tab_idx] + .botones[TabBotonera::idx(fila, col)].is_some(); + mostrar_menu_contextual( + &btn_c, x, y, + tab_idx, fila, col, + tiene_audio, + &td, &est, &btns_c, &grid_c, + ); + }); + btn.add_controller(gesture_right); + } + + // Drop → asignar audio + // DropTarget para STRING (drag interno) + let drop_str = DropTarget::new(glib::Type::STRING, gdk::DragAction::COPY); + { + let td = tabs_data.clone(); + let est = estado.clone(); + let btns_c = btns.clone(); + let grid_c = grid.clone(); + drop_str.connect_drop(move |_, value, _, _| { + let raw = value.get::().unwrap_or_default(); + let ruta = extraer_ruta(&raw); + if ruta.is_empty() { return false; } + asignar_audio(tab_idx, fila, col, ruta, &td, &est, &btns_c, &grid_c); + true + }); + } + btn.add_controller(drop_str); + + // DropTarget para text/uri-list (Thunar, Nautilus, etc.) + // Usamos gtk::gio::File via FileList + let drop_uri = DropTarget::new( + gtk::gio::File::static_type(), + gdk::DragAction::COPY + ); + { + let td = tabs_data.clone(); + let est = estado.clone(); + let btns_c = btns.clone(); + let grid_c = grid.clone(); + drop_uri.connect_drop(move |_, value, _, _| { + // Intentar como gio::File (un solo archivo) + if let Ok(file) = value.get::() { + if let Some(path) = file.path() { + let ruta = path.to_string_lossy().to_string(); + asignar_audio(tab_idx, fila, col, ruta, &td, &est, &btns_c, &grid_c); + return true; + } + } + false + }); + } + btn.add_controller(drop_uri); + } + } + + scroll.set_child(Some(&grid)); + + // Etiqueta renombrable de la pestaña + let nombre = tabs_data.borrow()[tab_idx].nombre.clone(); + let tab_lbl = construir_label_tab(tab_idx, nombre, tabs_data.clone()); + + (scroll.upcast::(), tab_lbl) +} + +// ─── Label de pestaña renombrable ───────────────────────────────────────── + +fn construir_label_tab( + tab_idx: usize, + nombre: String, + tabs_data: Rc>>, +) -> gtk::Widget { + let stack = gtk::Stack::new(); + stack.set_transition_type(gtk::StackTransitionType::None); + + let lbl = Label::new(Some(&nombre)); + lbl.add_css_class("tab-label"); + stack.add_named(&lbl, Some("label")); + + let entry = Entry::new(); + entry.set_width_chars(7); + entry.set_text(&nombre); + stack.add_named(&entry, Some("entry")); + + stack.set_visible_child_name("label"); + + // Doble clic → editar + let gesture = GestureClick::new(); + gesture.set_button(1); + { + let sc = stack.clone(); + let ec = entry.clone(); + let lc = lbl.clone(); + gesture.connect_released(move |_, n, _, _| { + if n == 2 { + ec.set_text(&lc.text()); + sc.set_visible_child_name("entry"); + ec.grab_focus(); + } + }); + } + lbl.add_controller(gesture); + + // Confirmar con Enter + { + let sc = stack.clone(); + let lc = lbl.clone(); + let td = tabs_data.clone(); + entry.connect_activate(move |e| { + let nuevo = e.text().to_string(); + lc.set_text(&nuevo); + td.borrow_mut()[tab_idx].nombre = nuevo; + storage_botonera::guardar(&td.borrow()); + sc.set_visible_child_name("label"); + }); + } + + // Confirmar al perder foco + { + let sc = stack.clone(); + let lc = lbl.clone(); + let td = tabs_data.clone(); + let ec2 = entry.clone(); + let focus_ctrl = gtk::EventControllerFocus::new(); + focus_ctrl.connect_leave(move |_| { + let nuevo = ec2.text().to_string(); + lc.set_text(&nuevo); + td.borrow_mut()[tab_idx].nombre = nuevo; + storage_botonera::guardar(&td.borrow()); + sc.set_visible_child_name("label"); + }); + entry.add_controller(focus_ctrl); + } + + stack.upcast::() +} + +// ─── Poblar botones del grid ─────────────────────────────────────────────── + +fn poblar_botones( + tab_idx: usize, + btns: &Rc>, + grid: &Grid, + tabs_data: &Rc>>, + estado: &Rc>, +) { + let td = tabs_data.borrow(); + let est = estado.borrow(); + + for fila in 0..FILAS { + for col in 0..COLS { + let idx = TabBotonera::idx(fila, col); + let btn = &btns[idx]; + + // Asegurar que está en el grid + if btn.parent().is_none() { + grid.attach(btn, col as i32, fila as i32, 1, 1); + } + + btn.remove_css_class("fx-btn"); + btn.remove_css_class("fx-vacio"); + btn.remove_css_class("fx-play"); + + let reproduciendo = est.reproduciendo.as_ref() + .map(|r| r.tab == tab_idx && r.fila == fila && r.col == col) + .unwrap_or(false); + + match &td[tab_idx].botones[idx] { + None => { + btn.set_label(""); + btn.set_widget_name(""); + btn.add_css_class("fx-vacio"); + } + Some(boton) => { + let wname = format!("fx{}{}{}", tab_idx, fila, col); + btn.set_widget_name(&wname); + btn.set_label(&boton.nombre); + if reproduciendo { + btn.add_css_class("fx-play"); + } else { + btn.add_css_class("fx-btn"); + } + } + } + } + } + drop(td); + drop(est); + actualizar_css_colores(tab_idx, tabs_data, estado); +} + +// ─── CSS dinámico de colores ─────────────────────────────────────────────── + +fn actualizar_css_colores( + tab_idx: usize, + tabs_data: &Rc>>, + estado: &Rc>, +) { + let td = tabs_data.borrow(); + let est = estado.borrow(); + let mut css = String::new(); + + for fila in 0..FILAS { + for col in 0..COLS { + let idx = TabBotonera::idx(fila, col); + if td[tab_idx].botones[idx].is_none() { continue; } + + let color = PALETA[idx % PALETA.len()]; + let texto = color_texto(color); + let wname = format!("fx{}{}{}", tab_idx, fila, col); + let reproduciendo = est.reproduciendo.as_ref() + .map(|r| r.tab == tab_idx && r.fila == fila && r.col == col) + .unwrap_or(false); + + if reproduciendo { + css.push_str(&format!( + "#{} {{ background:{}; color:{}; }}\n", + wname, color, texto + )); + } else { + css.push_str(&format!( + "#{} {{ background:{}; color:{}; }}\n", + wname, color, texto + )); + } + } + } + + est.css_provider.load_from_data(&css); +} + +// ─── Reproducción ───────────────────────────────────────────────────────── + +fn manejar_clic( + tab_idx: usize, fila: usize, col: usize, + tabs_data: &Rc>>, + estado: &Rc>, + btns: &Rc>, + grid: &Grid, +) { + let idx = TabBotonera::idx(fila, col); + let tiene_audio = tabs_data.borrow()[tab_idx].botones[idx].is_some(); + if !tiene_audio { return; } + + let ya_reproduciendo = estado.borrow().reproduciendo + .as_ref() + .map(|r| r.tab == tab_idx && r.fila == fila && r.col == col) + .unwrap_or(false); + + if ya_reproduciendo { + detener_reproduccion(estado); + } else { + detener_reproduccion(estado); + let ruta = tabs_data.borrow()[tab_idx].botones[idx] + .as_ref().map(|b| b.ruta.clone()); + if let Some(ruta) = ruta { + estado.borrow_mut().reproduciendo = Some(Reproduciendo { tab: tab_idx, fila, col }); + let uri = path_to_uri(&ruta); + if let Ok(elem) = gst::ElementFactory::make("playbin") + .property("uri", &uri) + .build() + { + if let Ok(pipeline) = elem.downcast::() { + // Bus watch: reset toggle cuando termina el audio + if let Some(bus) = pipeline.bus() { + let estado_w = estado.clone(); + let tabs_w = tabs_data.clone(); + let btns_w = btns.clone(); + let grid_w = grid.clone(); + let watch = bus.add_watch_local(move |_, msg| { + match msg.view() { + gst::MessageView::Eos(_) | gst::MessageView::Error(_) => { + if let Some(p) = estado_w.borrow_mut().pipeline.take() { + let _ = p.set_state(gst::State::Null); + } + estado_w.borrow_mut().reproduciendo = None; + poblar_botones(tab_idx, &btns_w, &grid_w, &tabs_w, &estado_w); + return glib::ControlFlow::Break; + } + _ => {} + } + glib::ControlFlow::Continue + }); + if let Ok(guard) = watch { std::mem::forget(guard); } + } + let _ = pipeline.set_state(gst::State::Playing); + estado.borrow_mut().pipeline = Some(pipeline); + } + } + } + } + poblar_botones(tab_idx, btns, grid, tabs_data, estado); +} + +fn detener_reproduccion(estado: &Rc>) { + if estado.borrow().reproduciendo.is_some() { + estado.borrow_mut().reproduciendo = None; + if let Some(p) = estado.borrow_mut().pipeline.take() { + let _ = p.set_state(gst::State::Null); + } + } +} + +// ─── Menú contextual (botón derecho) ────────────────────────────────────── + +fn mostrar_menu_contextual( + btn: &Button, + x: f64, y: f64, + tab_idx: usize, fila: usize, col: usize, + tiene_audio: bool, + tabs_data: &Rc>>, + estado: &Rc>, + btns: &Rc>, + grid: &Grid, +) { + let menu = gtk::gio::Menu::new(); + menu.append(Some(crate::i18n::tr("boto.menu_asignar")), Some("btn.asignar")); + if tiene_audio { + menu.append(Some(crate::i18n::tr("boto.menu_quitar")), Some("btn.quitar")); + } + + let popover = PopoverMenu::from_model(Some(&menu)); + popover.set_parent(btn); + popover.set_has_arrow(false); + let rect = gtk::gdk::Rectangle::new(x as i32, y as i32, 1, 1); + popover.set_pointing_to(Some(&rect)); + + let action_group = gtk::gio::SimpleActionGroup::new(); + + // ── Acción: Asignar audio ── + let asignar_action = gtk::gio::SimpleAction::new("asignar", None); + { + let td = tabs_data.clone(); + let est = estado.clone(); + let btns_c = btns.clone(); + let grid_c = grid.clone(); + let btn_c = btn.clone(); + let popover_c = popover.clone(); + asignar_action.connect_activate(move |_, _| { + popover_c.popdown(); + // Obtener la ventana raíz del botón + let ventana = btn_c.root() + .and_then(|r| r.downcast::().ok()); + abrir_selector_audio( + ventana.as_ref(), + tab_idx, fila, col, + &td, &est, &btns_c, &grid_c, + ); + }); + } + action_group.add_action(&asignar_action); + + // ── Acción: Quitar audio ── + if tiene_audio { + let quitar_action = gtk::gio::SimpleAction::new("quitar", None); + let td = tabs_data.clone(); + let est = estado.clone(); + let btns_c = btns.clone(); + let grid_c = grid.clone(); + let popover_c = popover.clone(); + quitar_action.connect_activate(move |_, _| { + { + let mut td_mut = td.borrow_mut(); + td_mut[tab_idx].botones[TabBotonera::idx(fila, col)] = None; + storage_botonera::guardar(&td_mut); + } + if estado_reproduce(&est, tab_idx, fila, col) { + est.borrow_mut().reproduciendo = None; + if let Some(p) = est.borrow_mut().pipeline.take() { + let _ = p.set_state(gst::State::Null); + } + } + poblar_botones(tab_idx, &btns_c, &grid_c, &td, &est); + popover_c.popdown(); + }); + action_group.add_action(&quitar_action); + } + + btn.insert_action_group("btn", Some(&action_group)); + popover.popup(); +} + +// ─── Selector de archivo ─────────────────────────────────────────────────── + +fn abrir_selector_audio( + ventana: Option<>k::Window>, + tab_idx: usize, fila: usize, col: usize, + tabs_data: &Rc>>, + estado: &Rc>, + btns: &Rc>, + grid: &Grid, +) { + let dialog = gtk::FileChooserDialog::new( + Some(crate::i18n::tr("boto.sel_audio")), + ventana, + gtk::FileChooserAction::Open, + &[ + (crate::i18n::tr("btn.cancel"), gtk::ResponseType::Cancel), + (crate::i18n::tr("btn.open"), gtk::ResponseType::Accept), + ], + ); + dialog.set_modal(true); + + // Filtro de audio + let filtro = gtk::FileFilter::new(); + filtro.set_name(Some(crate::i18n::tr("boto.filtro_audio"))); + for ext in &["*.mp3", "*.wav", "*.ogg", "*.flac", "*.aac", "*.m4a", "*.gradio"] { + filtro.add_pattern(ext); + } + filtro.add_mime_type("audio/*"); + dialog.add_filter(&filtro); + + let filtro_todo = gtk::FileFilter::new(); + filtro_todo.set_name(Some(crate::i18n::tr("boto.filtro_todos"))); + filtro_todo.add_pattern("*"); + dialog.add_filter(&filtro_todo); + + // Abrir en el directorio de inicio de G Radio si existe + // Abrir en $HOME sin restricciones + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")); + let _ = dialog.set_current_folder(Some( + >k::gio::File::for_path(&home) + )); + + let td = tabs_data.clone(); + let est = estado.clone(); + let btns_c = btns.clone(); + let grid_c = grid.clone(); + + dialog.connect_response(move |d, resp| { + if resp == gtk::ResponseType::Accept { + if let Some(file) = d.file() { + if let Some(path) = file.path() { + let ruta = path.to_string_lossy().to_string(); + asignar_audio(tab_idx, fila, col, ruta, &td, &est, &btns_c, &grid_c); + } + } + } + d.close(); + }); + + dialog.present(); +} + +fn estado_reproduce(estado: &Rc>, tab: usize, fila: usize, col: usize) -> bool { + estado.borrow().reproduciendo + .as_ref() + .map(|r| r.tab == tab && r.fila == fila && r.col == col) + .unwrap_or(false) +} + + +// ─── Helpers de drop ────────────────────────────────────────────────────── + +/// Extrae la primera ruta válida de un string (puede ser uri-list o ruta directa) +fn extraer_ruta(raw: &str) -> String { + raw.lines() + .map(|l| l.trim()) + .filter(|l| !l.starts_with('#') && !l.is_empty()) + .map(|l| { + if l.starts_with("file://") { + uri_decode(&l[7..]) + } else { + l.trim_end_matches('\r').to_string() + } + }) + .filter(|r| !r.is_empty()) + .next() + .unwrap_or_default() +} + +fn asignar_audio( + tab_idx: usize, fila: usize, col: usize, + ruta: String, + tabs_data: &Rc>>, + estado: &Rc>, + btns: &Rc>, + grid: &Grid, +) { + let nombre = nombre_desde_ruta(&ruta); + { + let mut td_mut = tabs_data.borrow_mut(); + td_mut[tab_idx].botones[TabBotonera::idx(fila, col)] = + Some(Boton { ruta, nombre }); + storage_botonera::guardar(&td_mut); + } + poblar_botones(tab_idx, btns, grid, tabs_data, estado); +} + + +// ─── Utilidades ──────────────────────────────────────────────────────────── + +/// Decodifica %XX de una URI path (ej: %20 → espacio) +fn uri_decode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let (Ok(h), Ok(l)) = ( + std::str::from_utf8(&bytes[i+1..i+2]), + std::str::from_utf8(&bytes[i+2..i+3]), + ) { + if let Ok(byte) = u8::from_str_radix(&format!("{}{}", h, l), 16) { + out.push(byte as char); + i += 3; + continue; + } + } + } + out.push(bytes[i] as char); + i += 1; + } + // Quitar \r al final si viene de uri-list + out.trim_end_matches('\r').to_string() +} diff --git a/src/ui/comun.rs b/src/ui/comun.rs new file mode 100644 index 0000000..dd6a7fd --- /dev/null +++ b/src/ui/comun.rs @@ -0,0 +1,95 @@ +// ui/comun.rs — Funciones compartidas entre módulos de UI + +use gtk4 as gtk; +use gtk::prelude::*; +use gtk::{ApplicationWindow, ListStore, TreeView}; +use gdk4; +use crate::models::TipoPautaje; +use crate::storage; + +/// Establece el ícono de la ventana (taskbar/titlebar) desde bytes PNG embebidos +pub fn aplicar_icono_ventana(ventana: &ApplicationWindow, bytes: &[u8]) { + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + let _ = loader.write(bytes); + let _ = loader.close(); + if let Some(pb) = loader.pixbuf() { + let tex = gdk4::Texture::for_pixbuf(&pb); + ventana.connect_realize(move |w| { + if let Some(surf) = w.surface() { + use gdk4::prelude::ToplevelExt; + if let Some(tl) = surf.dynamic_cast_ref::() { + tl.set_icon_list(&[tex.clone()]); + } + } + }); + } +} + +/// Ajusta el tamaño de la ventana según la resolución del monitor. +/// Nunca supera max_ancho/max_alto y nunca excede el área visible del monitor +/// (resta ~90px de título + taskbar). Usar 0 en max_ancho/max_alto = sin límite. +pub fn aplicar_tamanio_ventana(ventana: &ApplicationWindow, max_ancho: i32, max_alto: i32) { + aplicar_tamanio_ventana_pct(ventana, 1.0, 1.0, max_ancho, max_alto); +} + +/// Igual que aplicar_tamanio_ventana pero el tamaño base es un porcentaje +/// del monitor (pct_ancho, pct_alto ∈ 0.0-1.0). max_* recorta si el porcentaje +/// resulta mayor que el máximo deseado; 0 = sin límite superior. +pub fn aplicar_tamanio_ventana_pct( + ventana: &ApplicationWindow, + pct_ancho: f64, + pct_alto: f64, + max_ancho: i32, + max_alto: i32, +) { + let win = ventana.clone(); + ventana.connect_realize(move |w| { + if let Some(surf) = w.surface() { + let display = gtk4::prelude::WidgetExt::display(w); + use gdk4::prelude::DisplayExt; + if let Some(monitor) = display.monitor_at_surface(&surf) { + use gdk4::prelude::MonitorExt; + // geometry() ya excluye taskbars/paneles; solo restamos barra de título (~30px) + let geo = monitor.geometry(); + let disponible_ancho = ((geo.width() as f64 * pct_ancho) as i32 - 20).max(400); + let disponible_alto = ((geo.height() as f64 * pct_alto) as i32 - 90).max(400); + let ancho = if max_ancho > 0 { disponible_ancho.min(max_ancho) } else { disponible_ancho }; + let alto = if max_alto > 0 { disponible_alto.min(max_alto) } else { disponible_alto }; + win.set_default_size(ancho, alto); + } + } + }); +} + +/// Recarga el ListStore con las entradas del corte actual +pub fn recargar_store(store: &ListStore, tipo: &TipoPautaje, hora: u8, minuto: u8) { + store.clear(); + let entradas = storage::leer_entradas(tipo, hora, minuto); + for entrada in &entradas { + store.insert_with_values( + None, + &[ + (0, &entrada.nombre_display()), + (1, &entrada.dias), + (2, &entrada.inicio.format("%Y%m%d").to_string()), + (3, &entrada.fin.format("%Y%m%d").to_string()), + (4, &entrada.ruta), + ], + ); + } +} + +/// Retorna el índice (0-based) de la fila seleccionada en el TreeView +pub fn seleccion_indice(tree: &TreeView) -> Option { + let (paths, _) = tree.selection().selected_rows(); + paths.first().and_then(|p| { + p.indices().first().map(|i| *i as usize) + }) +} + +/// Selecciona la fila en el índice dado +pub fn seleccionar_fila(tree: &TreeView, idx: usize) { + if let Some(path) = gtk::TreePath::from_string(&idx.to_string()) { + tree.selection().select_path(&path); + } +} diff --git a/src/ui/dialogo_url.rs b/src/ui/dialogo_url.rs new file mode 100644 index 0000000..c0e2597 --- /dev/null +++ b/src/ui/dialogo_url.rs @@ -0,0 +1,216 @@ +// ui/dialogo_url.rs — Diálogo para agregar un URL de streaming al pautaje +// +// Formato guardado en el .com para streaming con duración: +// URL\tDURACION_SEGS|dias|inicio|fin +// Si duración = 0 se guarda solo la URL sin el tab. + +use gtk4 as gtk; +use gtk::prelude::*; +use gtk::{ + ApplicationWindow, Box as GtkBox, Button, Entry, Label, + ListBox, ListBoxRow, ListStore, Orientation, ScrolledWindow, SpinButton, Window, +}; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + +use crate::i18n::tr; +use crate::models::{EntradaPautaje, Estado}; +use crate::storage; +use crate::ui::comun::recargar_store; + +fn history_path() -> PathBuf { + dirs::home_dir().unwrap().join(".gradio/data/url_history") +} + +fn load_history() -> Vec { + std::fs::read_to_string(history_path()) + .unwrap_or_default() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.to_string()) + .collect() +} + +fn save_to_history(url: &str) { + let path = history_path(); + let mut history = load_history(); + history.retain(|u| u != url); + history.insert(0, url.to_string()); + history.truncate(20); + let content = history.join("\n") + "\n"; + let _ = std::fs::write(path, content); +} + +fn delete_from_history(url: &str) { + let path = history_path(); + let mut history = load_history(); + history.retain(|u| u != url); + let content = if history.is_empty() { + String::new() + } else { + history.join("\n") + "\n" + }; + let _ = std::fs::write(path, content); +} + +pub fn mostrar_dialogo_url( + padre: &ApplicationWindow, + estado: Rc>, + store_pautaje: ListStore, +) { + let win = Window::builder() + .title(tr("dialogo.url.title")) + .transient_for(padre) + .modal(true) + .default_width(480) + .resizable(true) + .default_height(360) + .build(); + + let vbox = GtkBox::new(Orientation::Vertical, 8); + vbox.set_margin_top(16); + vbox.set_margin_bottom(16); + vbox.set_margin_start(16); + vbox.set_margin_end(16); + + // ── URL ────────────────────────────────────────────────────────────── + vbox.append(&Label::new(Some(tr("dialogo.url.label")))); + let entry_url = Entry::new(); + entry_url.set_hexpand(true); + entry_url.set_placeholder_text(Some("http://stream.ejemplo.com:8000/radio")); + vbox.append(&entry_url); + + // ── Historial ──────────────────────────────────────────────────────── + let history_entries = load_history(); + if !history_entries.is_empty() { + let lbl_hist = Label::new(Some(tr("dialogo.url.history"))); + lbl_hist.set_halign(gtk4::Align::Start); + vbox.append(&lbl_hist); + + let hist_list = ListBox::new(); + hist_list.set_selection_mode(gtk4::SelectionMode::None); + + for url in &history_entries { + let row = ListBoxRow::new(); + row.set_selectable(false); + let hbox = GtkBox::new(Orientation::Horizontal, 6); + hbox.set_margin_top(2); + hbox.set_margin_bottom(2); + hbox.set_margin_start(4); + hbox.set_margin_end(4); + + let lbl = Label::new(Some(url.as_str())); + lbl.set_hexpand(true); + lbl.set_halign(gtk4::Align::Start); + lbl.set_max_width_chars(48); + lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + + let btn_use = Button::with_label(tr("dialogo.url.use")); + let btn_del = Button::with_label("✕"); + btn_del.set_tooltip_text(Some(tr("dialogo.url.delete_tip"))); + + hbox.append(&lbl); + hbox.append(&btn_use); + hbox.append(&btn_del); + row.set_child(Some(&hbox)); + hist_list.append(&row); + + // "Usar" → copia el URL al campo de texto + { + let entry_c = entry_url.clone(); + let url_c = url.clone(); + btn_use.connect_clicked(move |_| { + entry_c.set_text(&url_c); + }); + } + // "✕" → elimina del historial y oculta la fila + { + let url_c = url.clone(); + let row_c = row.clone(); + btn_del.connect_clicked(move |_| { + delete_from_history(&url_c); + row_c.set_visible(false); + }); + } + } + + let hist_scroll = ScrolledWindow::new(); + hist_scroll.set_vexpand(false); + hist_scroll.set_min_content_height(80); + hist_scroll.set_max_content_height(150); + hist_scroll.set_child(Some(&hist_list)); + vbox.append(&hist_scroll); + } + + // ── Duración ────────────────────────────────────────────────────────── + let hbox_dur = GtkBox::new(Orientation::Horizontal, 8); + hbox_dur.append(&Label::new(Some(tr("dialogo.url.duration")))); + + // SpinButton: 0 = sin límite, 1..86400 (24h) + let adj = gtk::Adjustment::new( + 30.0, // valor inicial + 0.0, // mínimo (0 = sin límite / continuo) + 86400.0, // máximo (24 horas) + 1.0, // paso + 60.0, // paso de página + 0.0, // tamaño de página (no usado) + ); + let spin_dur = SpinButton::new(Some(&adj), 1.0, 0); + spin_dur.set_width_chars(8); + spin_dur.set_tooltip_text(Some(tr("dialogo.url.duration_tip"))); + hbox_dur.append(&spin_dur); + + let lbl_hint = Label::new(Some(tr("dialogo.url.continuous_hint"))); + lbl_hint.add_css_class("dim-label"); + hbox_dur.append(&lbl_hint); + vbox.append(&hbox_dur); + + // ── Botones ─────────────────────────────────────────────────────────── + let hbox_btns = GtkBox::new(Orientation::Horizontal, 6); + hbox_btns.set_halign(gtk::Align::End); + let btn_cancelar = Button::with_label(tr("btn.cancel")); + let btn_aceptar = Button::with_label(tr("dialogo.url.add")); + btn_aceptar.add_css_class("suggested-action"); + hbox_btns.append(&btn_cancelar); + hbox_btns.append(&btn_aceptar); + vbox.append(&hbox_btns); + + win.set_child(Some(&vbox)); + + { + let wc = win.clone(); + btn_cancelar.connect_clicked(move |_| wc.close()); + } + { + let wc = win.clone(); + let eu = entry_url.clone(); + let sd = spin_dur.clone(); + btn_aceptar.connect_clicked(move |_| { + let url = eu.text().to_string(); + if !url.trim().is_empty() { + let duracion = sd.value() as u32; + // Formato de ruta: URL o URL\tSEGS si duracion > 0 + let ruta = if duracion > 0 { + format!("{}\t{}", url.trim(), duracion) + } else { + url.trim().to_string() + }; + let e = estado.borrow(); + let entrada = EntradaPautaje { + ruta, + dias: e.dias.clone(), + inicio: e.fecha_inicio, + fin: e.fecha_fin, + }; + storage::agregar_entrada(&e.tipo, e.hora, e.minuto, entrada); + recargar_store(&store_pautaje, &e.tipo, e.hora, e.minuto); + // Guardar en historial + save_to_history(url.trim()); + } + wc.close(); + }); + } + + win.present(); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..754d54b --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,9 @@ +pub mod comun; +pub mod ventana_principal; +pub mod panel_arbol; +pub mod panel_pautaje; +pub mod barra_herramientas; +pub mod dialogo_url; +pub mod parrilla; +pub mod botonera; +pub mod visor; diff --git a/src/ui/panel_arbol.rs b/src/ui/panel_arbol.rs new file mode 100644 index 0000000..dd9ba47 --- /dev/null +++ b/src/ui/panel_arbol.rs @@ -0,0 +1,259 @@ +// ui/panel_arbol.rs — Panel izquierdo: árbol de carpetas + lista de archivos +// +// Árbol con carga lazy (bajo demanda) para evitar congelamiento. +// Cada directorio muestra un hijo "cargando..." que se reemplaza al expandir. + +use gtk4 as gtk; +use gtk::prelude::*; +use gtk::{ + Box as GtkBox, Label, ListStore, Orientation, Paned, ScrolledWindow, + TreeStore, TreeView, TreeViewColumn, CellRendererText, TreeIter, +}; +use glib; +use std::cell::RefCell; +use std::rc::Rc; +use std::path::{Path, PathBuf}; + +use crate::i18n::tr; +use crate::models::{EntradaPautaje, Estado}; +use crate::storage; +use crate::ui::comun::recargar_store; + +// Valor centinela para nodos no expandidos aún +const DUMMY: &str = "⟳"; + +pub fn construir_panel_arbol( + estado: Rc>, + store_pautaje: ListStore, + _lbl_acumulado: Label, + _lbl_corte: Label, +) -> GtkBox { + let vbox = GtkBox::new(Orientation::Vertical, 0); + vbox.set_width_request(380); + + // TreeStore: nombre_visible | ruta | es_archivo + let tree_store = TreeStore::new(&[ + glib::Type::STRING, + glib::Type::STRING, + glib::Type::BOOL, + ]); + + poblar_raiz(&tree_store); + + let tree_view = TreeView::with_model(&tree_store); + tree_view.set_headers_visible(false); + tree_view.set_enable_tree_lines(true); + { + let r = CellRendererText::new(); + let c = TreeViewColumn::new(); + c.pack_start(&r, true); + c.add_attribute(&r, "text", 0); + tree_view.append_column(&c); + } + + // Carga lazy: al expandir un nodo, poblar sus hijos reales + { + let ts = tree_store.clone(); + tree_view.connect_row_expanded(move |_tv, iter, _path| { + expandir_nodo(&ts, iter); + }); + } + + // ListStore de archivos: nombre | ruta + let store_archivos = ListStore::new(&[glib::Type::STRING, glib::Type::STRING]); + + let list_view = TreeView::with_model(&store_archivos); + list_view.set_headers_visible(true); + { + let r = CellRendererText::new(); + let c = TreeViewColumn::new(); + c.set_title(tr("parr.col_nombre")); + c.pack_start(&r, true); + c.add_attribute(&r, "text", 0); + list_view.append_column(&c); + } + + // Seleccionar carpeta → poblar archivos + { + let sa = store_archivos.clone(); + tree_view.selection().connect_changed(move |sel| { + if let Some((model, iter)) = sel.selected() { + let es_archivo: bool = model.get::(&iter, 2); + if !es_archivo { + let ruta: String = model.get::(&iter, 1); + poblar_archivos(&sa, &ruta); + } + } + }); + } + + // Doble clic en archivo → agregar al pautaje + { + let ec = estado.clone(); + let sp = store_pautaje.clone(); + let sa = store_archivos.clone(); + list_view.connect_row_activated(move |_v, path, _col| { + if let Some(iter) = sa.iter(path) { + let ruta: String = sa.get::(&iter, 1); + agregar_al_pautaje(&ec, &sp, &ruta); + } + }); + } + + let scroll_arbol = ScrolledWindow::new(); + scroll_arbol.set_vexpand(true); + scroll_arbol.set_min_content_height(300); + scroll_arbol.set_child(Some(&tree_view)); + + let scroll_archivos = ScrolledWindow::new(); + scroll_archivos.set_vexpand(true); + scroll_archivos.set_min_content_height(150); + scroll_archivos.set_child(Some(&list_view)); + + let paned = Paned::new(Orientation::Vertical); + paned.set_position(300); + paned.set_start_child(Some(&scroll_arbol)); + paned.set_end_child(Some(&scroll_archivos)); + + vbox.append(&paned); + vbox +} + +// ─── Poblar raíz ─────────────────────────────────────────────────────────── + +fn poblar_raiz(store: &TreeStore) { + store.clear(); + let home = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/root")) + .to_string_lossy() + .into_owned(); + + // Nodo Inicio ($HOME) — expandir primer nivel + let iter_home = store.insert_with_values(None, None, &[ + (0, &format!("🏠 {}", tr("tree.home"))), + (1, &home), + (2, &false), + ]); + agregar_hijos_lazy(store, &iter_home, &PathBuf::from(&home)); + + // Nodo Sistema (/) — solo mostrar, carga lazy al expandir + let iter_sys = store.insert_with_values(None, None, &[ + (0, &format!("💾 {} (/)", tr("tree.system"))), + (1, &"/".to_string()), + (2, &false), + ]); + // Añadir dummy para que muestre la flecha de expansión + store.insert_with_values(Some(&iter_sys), None, &[ + (0, &DUMMY.to_string()), + (1, &"".to_string()), + (2, &false), + ]); +} + +// ─── Carga lazy al expandir ──────────────────────────────────────────────── + +fn expandir_nodo(store: &TreeStore, iter: &TreeIter) { + // Comprobar si el único hijo es el dummy + let n_hijos = store.iter_n_children(Some(iter)); + if n_hijos != 1 { return; } + + let hijo = match store.iter_children(Some(iter)) { + Some(h) => h, + None => return, + }; + let nombre: String = store.get::(&hijo, 0); + if nombre != DUMMY { return; } + + // Es dummy: reemplazar con hijos reales + let ruta: String = store.get::(iter, 1); + store.remove(&hijo); + agregar_hijos_lazy(store, iter, &PathBuf::from(&ruta)); +} + +fn agregar_hijos_lazy(store: &TreeStore, padre: &TreeIter, dir: &Path) { + let subdirs = match std::fs::read_dir(dir) { + Ok(d) => d, + Err(_) => return, + }; + let mut dirs: Vec = subdirs + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir() && !p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(true)) + .collect(); + dirs.sort(); + + for subdir in dirs { + let nombre = subdir.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string(); + let iter = store.insert_with_values(Some(padre), None, &[ + (0, &format!("📁 {}", nombre)), + (1, &subdir.to_string_lossy().to_string()), + (2, &false), + ]); + // Añadir dummy si tiene subdirectorios + if tiene_subdirs(&subdir) { + store.insert_with_values(Some(&iter), None, &[ + (0, &DUMMY.to_string()), + (1, &"".to_string()), + (2, &false), + ]); + } + } +} + +fn tiene_subdirs(dir: &Path) -> bool { + std::fs::read_dir(dir) + .map(|mut d| d.any(|e| e.map(|e| e.path().is_dir()).unwrap_or(false))) + .unwrap_or(false) +} + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +fn poblar_archivos(store: &ListStore, ruta: &str) { + store.clear(); + // Primera fila: pautar toda la carpeta aleatoriamente + store.insert_with_values(None, &[ + (0, &format!("📁 {}", tr("arbol.pautar_carpeta"))), + (1, &format!("{}/*", ruta)), + ]); + const EXTS: &[&str] = &["mp3", "wav", "ogg", "flac", "aac", "m4a", "gradio"]; + if let Ok(entradas) = std::fs::read_dir(ruta) { + let mut archivos: Vec = entradas + .filter_map(|e| e.ok()).map(|e| e.path()) + .filter(|p| p.is_file() && p.extension() + .and_then(|x| x.to_str()) + .map(|x| EXTS.contains(&x.to_lowercase().as_str())) + .unwrap_or(false)) + .collect(); + archivos.sort(); + for archivo in archivos { + let nombre = archivo.file_name() + .and_then(|n| n.to_str()).unwrap_or("?").to_string(); + store.insert_with_values(None, &[ + (0, &nombre), + (1, &archivo.to_string_lossy().to_string()), + ]); + } + } +} + +fn agregar_al_pautaje( + estado: &Rc>, + store_pautaje: &ListStore, + ruta: &str, +) { + let e = estado.borrow(); + let entrada = EntradaPautaje { + ruta: ruta.to_string(), + dias: e.dias.clone(), + inicio: e.fecha_inicio, + fin: e.fecha_fin, + }; + storage::agregar_entrada(&e.tipo, e.hora, e.minuto, entrada); + recargar_store(store_pautaje, &e.tipo, e.hora, e.minuto); +} diff --git a/src/ui/panel_pautaje.rs b/src/ui/panel_pautaje.rs new file mode 100644 index 0000000..cdebb31 --- /dev/null +++ b/src/ui/panel_pautaje.rs @@ -0,0 +1,24 @@ +// ui/panel_pautaje.rs — Helpers para cálculo de tiempos del panel de pautaje +// +// En el futuro, cuando se integre lectura real de duración de audio (ej. con +// symphonia o gstreamer-pbutils), las funciones de este módulo proveerán +// los tiempos para lbl_acumulado y lbl_corte. + +use crate::models::TipoPautaje; +use crate::storage; + +/// Tiempo total acumulado (segundos) de todos los cortes de una hora +pub fn tiempo_acumulado_hora(tipo: &TipoPautaje, hora: u8) -> u64 { + (0u8..60) + .step_by(5) + .map(|m| tiempo_corte(tipo, hora, m)) + .sum() +} + +/// Tiempo total (segundos) de un corte específico +pub fn tiempo_corte(tipo: &TipoPautaje, hora: u8, minuto: u8) -> u64 { + storage::leer_entradas(tipo, hora, minuto) + .iter() + .map(|e| e.duracion_secs()) + .sum() +} diff --git a/src/ui/parrilla/mod.rs b/src/ui/parrilla/mod.rs new file mode 100644 index 0000000..52a7d05 --- /dev/null +++ b/src/ui/parrilla/mod.rs @@ -0,0 +1,2 @@ +// ui/parrilla/mod.rs +pub mod ventana_parrilla; diff --git a/src/ui/parrilla/ventana_parrilla.rs b/src/ui/parrilla/ventana_parrilla.rs new file mode 100644 index 0000000..2b6ae03 --- /dev/null +++ b/src/ui/parrilla/ventana_parrilla.rs @@ -0,0 +1,890 @@ +// ui/parrilla/ventana_parrilla.rs — Parrilla Musical Específica de G-Radio + +use gtk4 as gtk; +use gtk::prelude::*; +use gtk::{ + Application, ApplicationWindow, Box as GtkBox, Button, Entry, Frame, Image, + FileChooserAction, FileChooserDialog, Label, ListStore, Orientation, + Paned, ScrolledWindow, Separator, + TreeStore, TreeView, TreeViewColumn, CellRendererText, TreeIter, + SelectionMode, DragSource, DropTarget, +}; +use gtk::gdk; +use gtk::gio; +use gdk4::FileList; +use glib; +use std::cell::RefCell; +use std::rc::Rc; +use std::path::PathBuf; + +use crate::i18n::tr; +use crate::storage_parrilla; +use crate::ui::comun; +use crate::skin; + +const ICONO_PARRILLA: &[u8] = include_bytes!("../../../assets/parrilla48x48.png"); +const ICON_TRASH: &[u8] = include_bytes!("../../../assets/trash.png"); +const ICON_UP: &[u8] = include_bytes!("../../../assets/up.png"); +const ICON_DOWN: &[u8] = include_bytes!("../../../assets/down.png"); + +fn icon_btn(data: &[u8], tooltip: &str) -> Button { + let btn = Button::new(); + btn.set_tooltip_text(Some(tooltip)); + let loader = gdk4::gdk_pixbuf::PixbufLoader::new(); + loader.write(data).unwrap_or(()); + loader.close().unwrap_or(()); + if let Some(pixbuf) = loader.pixbuf() { + if let Some(scaled) = pixbuf.scale_simple( + 22, 22, gdk4::gdk_pixbuf::InterpType::Bilinear, + ) { + let texture = gdk4::Texture::for_pixbuf(&scaled); + let image = Image::from_paintable(Some(&texture)); + btn.set_child(Some(&image)); + } + } + btn +} + +// ─── Estado ──────────────────────────────────────────────────────────────── + +#[derive(Debug)] +struct EstadoParrilla { + dia: u8, + hora: u8, +} + +impl Default for EstadoParrilla { + fn default() -> Self { EstadoParrilla { dia: 1, hora: 0 } } +} + +// ─── Ventana principal ───────────────────────────────────────────────────── + +pub fn construir_ventana_parrilla(app: &Application) { + storage_parrilla::crear_directorios(); + + let estado = Rc::new(RefCell::new(EstadoParrilla::default())); + + let ventana = ApplicationWindow::builder() + .application(app) + .title(tr("parr.win_title")) + .default_width(1010) + .default_height(700) + .build(); + + // CSS registrado usando el display de la ventana — garantizado válido + let css = gtk::CssProvider::new(); + css.load_from_data(concat!( + // Seleccionados — rojo + "button.hora-sel {", + " background: #b71c1c; color: white;", + " padding: 4px 2px; min-height: 24px;", + "}", + "button.hora-sel:hover { background: #c62828; }", + "button.dia-sel {", + " background: #b71c1c; color: white;", + " padding: 6px 4px; min-height: 28px;", + "}", + "button.dia-sel:hover { background: #c62828; }", + // Botón Grabar + "button.grabar-btn {", + " background: #b71c1c; color: white; font-weight: bold;", + " padding: 6px 4px; min-height: 28px;", + "}", + "button.grabar-btn:hover { background: #c62828; }", + // Arrastrando + "button.arrastrando { opacity: 0.4; }", + // Etiquetas + "label.titulo { font-weight: bold; font-size: 11pt; }", + "label.subtitulo { font-size: 8pt; color: #aaaaaa; }", + )); + // Usar el display de la ventana, no el default global + gtk::style_context_add_provider_for_display( + >k4::prelude::WidgetExt::display(&ventana), + &css, + gtk::STYLE_PROVIDER_PRIORITY_USER, + ); + skin::aplicar_css_extra(>k4::prelude::WidgetExt::display(&ventana)); + + let vbox_raiz = GtkBox::new(Orientation::Vertical, 0); + + // ── Encabezado ─────────────────────────────────────────────────────────── + let hbox_titulo = GtkBox::new(Orientation::Horizontal, 6); + hbox_titulo.set_margin_start(8); + hbox_titulo.set_margin_end(8); + hbox_titulo.set_margin_top(6); + + let lbl_titulo = Label::new(Some(tr("parr.titulo"))); + lbl_titulo.add_css_class("titulo"); + lbl_titulo.set_halign(gtk::Align::Start); + hbox_titulo.append(&lbl_titulo); + + let spacer_titulo = GtkBox::new(Orientation::Horizontal, 0); + spacer_titulo.set_hexpand(true); + hbox_titulo.append(&spacer_titulo); + + let lbl_pis = Label::new(Some(tr("parr.pisadores_hora"))); + hbox_titulo.append(&lbl_pis); + + let entry_pis = Entry::new(); + entry_pis.set_width_chars(30); + entry_pis.set_placeholder_text(Some(tr("parr.pisadores_ph"))); + entry_pis.set_hexpand(false); + hbox_titulo.append(&entry_pis); + + let btn_pis_browse = Button::with_label("📂"); + btn_pis_browse.set_tooltip_text(Some(tr("parr.pis_browse_tip"))); + hbox_titulo.append(&btn_pis_browse); + + // Conectar botón de exploración de carpeta de pisadores + { + let entry_ref = entry_pis.clone(); + let win_ref = ventana.clone(); + btn_pis_browse.connect_clicked(move |_| { + let fc = FileChooserDialog::new( + Some(tr("parr.pis_browse_tip")), + Some(&win_ref), + FileChooserAction::SelectFolder, + &[(tr("btn.cancel"), gtk::ResponseType::Cancel), + (tr("btn.select"), gtk::ResponseType::Accept)], + ); + fc.set_modal(true); + if !entry_ref.text().is_empty() { + let _ = fc.set_current_folder( + Some(&gio::File::for_path(entry_ref.text().as_str())) + ); + } + let entry_c = entry_ref.clone(); + fc.connect_response(move |fc, resp| { + if resp == gtk::ResponseType::Accept { + if let Some(file) = fc.file() { + if let Some(path) = file.path() { + entry_c.set_text(&path.to_string_lossy()); + } + } + } + fc.close(); + }); + fc.present(); + }); + } + + vbox_raiz.append(&hbox_titulo); + + let lbl_sub = Label::new(Some(tr("parr.fuentes_audio"))); + lbl_sub.add_css_class("subtitulo"); + lbl_sub.set_halign(gtk::Align::Start); + lbl_sub.set_margin_start(8); + vbox_raiz.append(&lbl_sub); + vbox_raiz.append(&Separator::new(Orientation::Horizontal)); + + // ListStore: columna 0 = texto del ítem + let store_items = ListStore::new(&[glib::Type::STRING]); + + let tree_items = Rc::new({ + let t = TreeView::with_model(&store_items); + t.set_headers_visible(false); + t.selection().set_mode(SelectionMode::Single); + let renderer = CellRendererText::new(); + let col = TreeViewColumn::new(); + col.pack_start(&renderer, true); + col.add_attribute(&renderer, "text", 0); + t.append_column(&col); + t + }); + + // ── Layout: izquierda | (centro + derecha) ──────────────────────────── + let paned_main = Paned::new(Orientation::Horizontal); + paned_main.set_vexpand(true); + paned_main.set_position(390); + + let panel_izq = construir_panel_izquierdo( + store_items.clone(), + tree_items.clone(), + ); + paned_main.set_start_child(Some(&panel_izq)); + + let hbox_der = GtkBox::new(Orientation::Horizontal, 0); + hbox_der.set_hexpand(true); + hbox_der.set_vexpand(true); + + let panel_centro = construir_panel_centro( + store_items.clone(), + tree_items.clone(), + ); + panel_centro.set_hexpand(true); + hbox_der.append(&panel_centro); + hbox_der.append(&Separator::new(Orientation::Vertical)); + + let panel_der = construir_panel_derecho( + estado.clone(), + store_items.clone(), + entry_pis.clone(), + ); + hbox_der.append(&panel_der); + + paned_main.set_end_child(Some(&hbox_der)); + vbox_raiz.append(&paned_main); + + ventana.set_child(Some(&vbox_raiz)); + comun::aplicar_icono_ventana(&ventana, &skin::icono("parrilla48x48.png", ICONO_PARRILLA)); + comun::aplicar_tamanio_ventana(&ventana, 1100, 700); + ventana.present(); +} + +// ─── Panel izquierdo ────────────────────────────────────────────────────── + +fn construir_panel_izquierdo( + store_items: ListStore, + tree_items: Rc, +) -> GtkBox { + let vbox = GtkBox::new(Orientation::Vertical, 0); + vbox.set_width_request(390); + + let tree_store = TreeStore::new(&[ + glib::Type::STRING, + glib::Type::STRING, + glib::Type::BOOL, + ]); + poblar_arbol(&tree_store); + + let tree_view = TreeView::with_model(&tree_store); + tree_view.set_headers_visible(false); + tree_view.set_enable_tree_lines(true); + { + let r = CellRendererText::new(); + let c = TreeViewColumn::new(); + c.pack_start(&r, true); + c.add_attribute(&r, "text", 0); + tree_view.append_column(&c); + } + // Carga lazy al expandir + { + let ts = tree_store.clone(); + tree_view.connect_row_expanded(move |_tv, iter, _path| { + expandir_nodo_par(&ts, iter); + }); + } + + let store_arch = ListStore::new(&[glib::Type::STRING, glib::Type::STRING]); + + let list_arch = TreeView::with_model(&store_arch); + list_arch.set_headers_visible(true); + { + let r = CellRendererText::new(); + let c = TreeViewColumn::new(); + c.set_title(tr("parr.col_nombre")); + c.pack_start(&r, true); + c.add_attribute(&r, "text", 0); + list_arch.append_column(&c); + } + + { + let sa = store_arch.clone(); + tree_view.selection().connect_changed(move |sel| { + if let Some((model, iter)) = sel.selected() { + let es_archivo: bool = model.get::(&iter, 2); + if !es_archivo { + let ruta: String = model.get::(&iter, 1); + poblar_archivos(&sa, &ruta); + } + } + }); + } + + { + let si = store_items.clone(); + let ti = tree_items.clone(); + let sa = store_arch.clone(); + list_arch.connect_row_activated(move |_v, path, _col| { + if let Some(iter) = sa.iter(path) { + let ruta: String = sa.get::(&iter, 1); + agregar_item_ui(&si, &ti, ruta); + } + }); + } + + { + let si = store_items.clone(); + let ti = tree_items.clone(); + tree_view.connect_row_activated(move |view, path, _col| { + if let Some((model, iter)) = view.model().and_then(|m| m.iter(path).map(|i| (m, i))) { + let es_archivo: bool = model.get::(&iter, 2); + if !es_archivo { + let ruta: String = model.get::(&iter, 1); + agregar_item_ui(&si, &ti, format!("{}/*", ruta)); + } + } + }); + } + + let scroll_arbol = ScrolledWindow::new(); + scroll_arbol.set_vexpand(true); + scroll_arbol.set_min_content_height(280); + scroll_arbol.set_child(Some(&tree_view)); + + let scroll_arch = ScrolledWindow::new(); + scroll_arch.set_vexpand(true); + scroll_arch.set_min_content_height(130); + scroll_arch.set_child(Some(&list_arch)); + + let paned = Paned::new(Orientation::Vertical); + paned.set_position(280); + paned.set_start_child(Some(&scroll_arbol)); + paned.set_end_child(Some(&scroll_arch)); + + vbox.append(&paned); + vbox +} + +// ─── Panel central ──────────────────────────────────────────────────────── + +fn construir_panel_centro( + store_items: ListStore, + tree_items: Rc, +) -> GtkBox { + let vbox = GtkBox::new(Orientation::Vertical, 0); + vbox.set_hexpand(true); + + let hbox_btns = GtkBox::new(Orientation::Horizontal, 4); + hbox_btns.set_margin_start(4); + hbox_btns.set_margin_end(4); + hbox_btns.set_margin_top(4); + hbox_btns.set_margin_bottom(4); + + let btn_hora = Button::with_label("⏰"); + btn_hora.set_tooltip_text(Some(tr("parr.btn_marca_hora"))); + { + let si = store_items.clone(); + let ti = tree_items.clone(); + btn_hora.connect_clicked(move |_| { + agregar_item_ui(&si, &ti, "Hora".to_string()); + }); + } + hbox_btns.append(&btn_hora); + + let btn_subir = icon_btn(&skin::icono("up.png", ICON_UP), tr("parr.subir_item")); + { + let si = store_items.clone(); + let ti = tree_items.clone(); + btn_subir.connect_clicked(move |_| { + if let Some(idx) = seleccion_indice(&ti) { + mover_en_store(&si, idx, true); + seleccionar_fila_items(&ti, idx.saturating_sub(1)); + } + }); + } + hbox_btns.append(&btn_subir); + + let btn_bajar = icon_btn(&skin::icono("down.png", ICON_DOWN), tr("parr.bajar_item")); + { + let si = store_items.clone(); + let ti = tree_items.clone(); + btn_bajar.connect_clicked(move |_| { + if let Some(idx) = seleccion_indice(&ti) { + let n = store_items_count(&si); + mover_en_store(&si, idx, false); + seleccionar_fila_items(&ti, (idx + 1).min(n.saturating_sub(1))); + } + }); + } + hbox_btns.append(&btn_bajar); + + let btn_del = icon_btn(&skin::icono("trash.png", ICON_TRASH), tr("parr.eliminar_item")); + { + let si = store_items.clone(); + let ti = tree_items.clone(); + btn_del.connect_clicked(move |_| { + if let Some(idx) = seleccion_indice(&ti) { + eliminar_en_store(&si, idx); + } + }); + } + hbox_btns.append(&btn_del); + + hbox_btns.append(&Separator::new(Orientation::Vertical)); + let lbl = Label::new(Some(tr("parr.parrilla_musical"))); + lbl.set_hexpand(true); + lbl.set_halign(gtk::Align::End); + hbox_btns.append(&lbl); + + vbox.append(&hbox_btns); + vbox.append(&Separator::new(Orientation::Horizontal)); + + // ── DropTarget para archivos externos desde Nemo (igual que botonera) ── + { + let si = store_items.clone(); + let ti = tree_items.clone(); + + let drop_target = DropTarget::new(gtk::gio::File::static_type(), gdk::DragAction::COPY); + + drop_target.connect_drop(move |_, value, _, _| { + if let Ok(file) = value.get::() { + if let Some(path) = file.path() { + // Si es carpeta, agregar con /* (aleatorio) + if path.is_dir() { + let ruta = format!("{}/*", path.to_string_lossy()); + agregar_item_ui(&si, &ti, ruta); + return true; + } + // Si es archivo de audio, agregar directamente + let ext = path.extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if matches!(ext.as_str(), "mp3"|"wav"|"ogg"|"flac"|"aac"|"m4a") { + agregar_item_ui(&si, &ti, path.to_string_lossy().to_string()); + return true; + } + } + } + false + }); + + tree_items.add_controller(drop_target); + } + + let scroll = ScrolledWindow::new(); + scroll.set_hexpand(true); + scroll.set_vexpand(true); + scroll.set_child(Some(tree_items.as_ref())); + vbox.append(&scroll); + + vbox +} + +// ─── Panel derecho ──────────────────────────────────────────────────────── +// +// Drag & Drop — formato de datos transferidos: texto simple +// "H:nn" → arrastrando hora nn (ej: "H:05") +// "D:n" → arrastrando día n (ej: "D:3" = Miércoles) + +fn construir_panel_derecho( + estado: Rc>, + store_items: ListStore, + entry_pis: Entry, +) -> GtkBox { + let vbox = GtkBox::new(Orientation::Vertical, 6); + vbox.set_width_request(220); + vbox.set_margin_start(4); + vbox.set_margin_end(4); + vbox.set_margin_top(4); + vbox.set_margin_bottom(4); + + // Tipo de dato para drag & drop: String con formato "H:nn" o "D:n" + + // ── Leer / Grabar — acceso rápido sobre "Horas" ─────────────────────── + let hbox_acc = GtkBox::new(Orientation::Horizontal, 4); + + let btn_leer = Button::with_label(tr("parr.btn_leer")); + btn_leer.set_hexpand(true); + btn_leer.set_tooltip_text(Some(tr("parr.btn_leer_tip"))); + { + let ec = estado.clone(); + let si = store_items.clone(); + let ep = entry_pis.clone(); + btn_leer.connect_clicked(move |_| { + let e = ec.borrow(); + recargar_items(&si, e.dia, e.hora); + ep.set_text(&storage_parrilla::leer_pisador_dir(e.dia, e.hora)); + }); + } + hbox_acc.append(&btn_leer); + + let btn_grabar = Button::with_label(tr("parr.btn_grabar")); + btn_grabar.set_hexpand(true); + btn_grabar.add_css_class("grabar-btn"); + btn_grabar.set_tooltip_text(Some(tr("parr.btn_grabar_tip"))); + { + let ec = estado.clone(); + let si = store_items.clone(); + let ep = entry_pis.clone(); + btn_grabar.connect_clicked(move |_| { + let e = ec.borrow(); + let items = store_a_vec(&si); + storage_parrilla::escribir_items(e.dia, e.hora, &items); + storage_parrilla::escribir_pisador_dir(e.dia, e.hora, &ep.text()); + }); + } + hbox_acc.append(&btn_grabar); + vbox.append(&hbox_acc); + + // ── Frame de horas ──────────────────────────────────────────────────── + let frame_horas = Frame::new(Some(tr("parr.frame_horas"))); + let grid_horas = gtk::Grid::new(); + grid_horas.set_column_spacing(2); + grid_horas.set_row_spacing(2); + grid_horas.set_column_homogeneous(true); + grid_horas.set_margin_top(4); + grid_horas.set_margin_bottom(4); + grid_horas.set_margin_start(4); + grid_horas.set_margin_end(4); + + let btns_hora: Vec