commit 2219b3abc07d5320b4dd987c4fa81ce5465b6cd5 Author: Charles Escobar Date: Sun Aug 23 01:15:15 2026 -0500 Commit inicial: G Radio Remoto (gr-client) Cliente remoto GTK4 para G Radio Player: se conecta al servidor TCP embebido en radio-player para controlar el reproductor y editar programación (pautaje, parrilla, botonera, buscador, reportes) desde otra máquina de la LAN. 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 0000000..536c0f8 Binary files /dev/null and b/Manual_GR_Client.docx differ diff --git a/Manual_GR_Client.odt b/Manual_GR_Client.odt new file mode 100644 index 0000000..971ccb7 Binary files /dev/null and b/Manual_GR_Client.odt differ diff --git a/assets/gr-client.png b/assets/gr-client.png new file mode 100644 index 0000000..dd0eb57 Binary files /dev/null and b/assets/gr-client.png differ diff --git a/assets/gr-client.svg b/assets/gr-client.svg new file mode 100644 index 0000000..13404f7 --- /dev/null +++ b/assets/gr-client.svg @@ -0,0 +1,480 @@ + + + + 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."