Commit inicial: G Radio Remoto (gr-client)

Cliente remoto GTK4 para G Radio Player: se conecta al servidor TCP
embebido en radio-player para controlar el reproductor y editar
programación (pautaje, parrilla, botonera, buscador, reportes) desde
otra máquina de la LAN.
This commit is contained in:
2026-08-23 01:15:15 -05:00
commit 2219b3abc0
28 changed files with 7332 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
// panel_botonera.rs — Botonera remota
use gtk4::prelude::*;
use gtk4::{
Align, Box as GtkBox, Button, Entry, FlowBox, Label, Orientation, ScrolledWindow, Separator,
};
use std::cell::RefCell;
use std::rc::Rc;
use crate::conexion::Conexion;
use crate::protocol::{BotonRemoto, MsgCliente};
#[derive(Clone)]
pub struct PanelBotonera {
pub root: GtkBox,
flow: FlowBox,
conexion: Rc<RefCell<Option<Conexion>>>,
botones: Rc<RefCell<Vec<BotonRemoto>>>,
}
impl PanelBotonera {
pub fn nuevo(conexion: &Rc<RefCell<Option<Conexion>>>) -> Self {
let root = GtkBox::new(Orientation::Vertical, 4);
root.set_margin_top(6);
root.set_margin_bottom(6);
root.set_margin_start(6);
root.set_margin_end(6);
let toolbar = GtkBox::new(Orientation::Horizontal, 6);
let btn_cargar = Button::with_label("⟳ Cargar");
let btn_guardar = Button::with_label("💾 Guardar");
btn_guardar.add_css_class("suggested-action");
toolbar.append(&btn_cargar);
toolbar.append(&btn_guardar);
root.append(&toolbar);
root.append(&Separator::new(Orientation::Horizontal));
let lbl = Label::new(Some(
"Clic → reproducir en servidor. Clic derecho → editar botón.",
));
lbl.add_css_class("dim-label");
lbl.set_margin_top(8);
lbl.set_margin_bottom(8);
root.append(&lbl);
let flow = FlowBox::new();
flow.set_max_children_per_line(8);
flow.set_min_children_per_line(4);
flow.set_selection_mode(gtk4::SelectionMode::None);
flow.set_homogeneous(true);
flow.set_row_spacing(4);
flow.set_column_spacing(4);
flow.set_margin_top(8);
flow.set_margin_bottom(8);
flow.set_margin_start(8);
flow.set_margin_end(8);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&flow));
root.append(&scroll);
let botones: Rc<RefCell<Vec<BotonRemoto>>> = Rc::new(RefCell::new(Vec::new()));
{
let conexion2 = conexion.clone();
btn_cargar.connect_clicked(move |_| {
if let Some(ref con) = *conexion2.borrow() {
con.enviar(MsgCliente::ObtenerBotonera);
}
});
}
{
let botones2 = botones.clone();
let conexion2 = conexion.clone();
btn_guardar.connect_clicked(move |_| {
let lista = botones2.borrow().clone();
if let Some(ref con) = *conexion2.borrow() {
con.enviar(MsgCliente::GuardarBotonera { botones: lista });
}
});
}
PanelBotonera { root, flow, conexion: conexion.clone(), botones }
}
pub fn cargar_botones(&self, botones: &[BotonRemoto]) {
while let Some(child) = self.flow.first_child() {
self.flow.remove(&child);
}
*self.botones.borrow_mut() = botones.to_vec();
for boton in botones {
let btn = Button::with_label(&boton.etiqueta);
btn.set_size_request(90, 60);
btn.add_css_class("botonera-btn");
let ruta = boton.ruta.clone();
let conexion2 = self.conexion.clone();
btn.connect_clicked(move |_| {
if let Some(ref con) = *conexion2.borrow() {
con.enviar(MsgCliente::ReproducirAhora { ruta: ruta.clone() });
}
});
// Clic derecho → editar
let gc = gtk4::GestureClick::new();
gc.set_button(3);
let btn2 = btn.clone();
let indice = boton.indice;
let botones2 = self.botones.clone();
gc.connect_pressed(move |_gc, _n, _x, _y| {
let padre: Option<gtk4::Window> = btn2
.root()
.and_then(|r| r.downcast::<gtk4::Window>().ok());
abrir_dialogo_editar_boton(
padre.as_ref(),
&btn2.label().unwrap_or_default(),
{
let b = botones2.borrow();
b.iter().find(|b| b.indice == indice)
.map(|b| b.ruta.clone())
.unwrap_or_default()
},
{
let btn3 = btn2.clone();
let b2 = botones2.clone();
move |nueva_lbl, nueva_ruta| {
btn3.set_label(&nueva_lbl);
if let Some(b) = b2.borrow_mut().iter_mut().find(|b| b.indice == indice) {
b.etiqueta = nueva_lbl;
b.ruta = nueva_ruta;
}
}
},
);
});
btn.add_controller(gc);
self.flow.insert(&btn, -1);
}
}
pub fn set_activo(&self, activo: bool) {
self.flow.set_sensitive(activo);
}
}
fn abrir_dialogo_editar_boton<F: Fn(String, String) + 'static>(
padre: Option<&gtk4::Window>,
etiqueta_actual: &str,
ruta_actual: String,
on_accept: F,
) {
let win = gtk4::Window::new();
win.set_title(Some("Editar botón"));
win.set_modal(true);
win.set_resizable(false);
if let Some(p) = padre { win.set_transient_for(Some(p)); }
let vbox = GtkBox::new(Orientation::Vertical, 8);
vbox.set_margin_top(12);
vbox.set_margin_bottom(12);
vbox.set_margin_start(16);
vbox.set_margin_end(16);
let f1 = GtkBox::new(Orientation::Horizontal, 8);
f1.append(&Label::new(Some("Etiqueta:")));
let e_lbl = Entry::new();
e_lbl.set_text(etiqueta_actual);
e_lbl.set_hexpand(true);
f1.append(&e_lbl);
vbox.append(&f1);
let f2 = GtkBox::new(Orientation::Horizontal, 8);
f2.append(&Label::new(Some("Ruta:")));
let e_ruta = Entry::new();
e_ruta.set_text(&ruta_actual);
e_ruta.set_hexpand(true);
f2.append(&e_ruta);
vbox.append(&f2);
let fila_btn = GtkBox::new(Orientation::Horizontal, 8);
fila_btn.set_halign(Align::End);
fila_btn.set_margin_top(8);
let btn_cancel = Button::with_label("Cancelar");
let btn_ok = Button::with_label("Aceptar");
btn_ok.add_css_class("suggested-action");
fila_btn.append(&btn_cancel);
fila_btn.append(&btn_ok);
vbox.append(&fila_btn);
win.set_child(Some(&vbox));
let on_accept = Rc::new(on_accept);
{
let win2 = win.clone();
let e_lbl2 = e_lbl.clone();
let e_ruta2 = e_ruta.clone();
let oa2 = on_accept.clone();
btn_ok.connect_clicked(move |_| {
on_accept(e_lbl2.text().to_string(), e_ruta2.text().to_string());
win2.close();
});
}
{
let win2 = win.clone();
btn_cancel.connect_clicked(move |_| { win2.close(); });
}
win.present();
}