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