// SPDX-License-Identifier: GPL-3.0-or-later
//
// radio-player/src/main.rs
// Reproductor de Radio con GTK4 + GStreamer
// Maneja música, comerciales, fundido cruzado y control de volumen

use gtk4::prelude::*;
use gtk4::{
    Application, ApplicationWindow, Box as GtkBox, Button, Label, Orientation,
    Scale, Adjustment, ToggleButton, Frame, CssProvider,
    ScrolledWindow, ListBox, ListBoxRow, GestureClick,
    DragSource, DropTarget,
    STYLE_PROVIDER_PRIORITY_APPLICATION,
    Entry, DrawingArea,
};

use gdk4::{ContentProvider, FileList};
use cairo;
use gtk4::gio;
use glib::types::StaticType;
use glib::{timeout_add_local};
use gstreamer as gst;
use gstreamer::prelude::*;
use gstreamer_pbutils as gst_pbutils;
use gstreamer_app as gst_app;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use std::path::{Path, PathBuf};
use std::fs;
use rand::seq::SliceRandom;
use chrono::{Local, Timelike};
use anyhow::Result;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
use log::{info, warn, error};
use grpautaje::skin;

mod servidor;
mod relay;
mod processor;
mod i18n;

// DSP compartido entre todos los pipelines activos.
// Almacena el mismo Arc que AppState.dsp → update_config() afecta en tiempo real.
static PROC_DSP: std::sync::OnceLock<std::sync::Mutex<Option<processor::SharedDsp>>>
    = std::sync::OnceLock::new();

fn proc_dsp_global() -> &'static std::sync::Mutex<Option<processor::SharedDsp>> {
    PROC_DSP.get_or_init(|| std::sync::Mutex::new(None))
}

// Versión de config: se incrementa en cada apply() de la ventana de config.
// El pad probe de cada pipeline la compara con su versión local; si difiere,
// recarga la config del master y llama update_config() en su DspProcessor propio.
static PROC_CFG_VER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
// Control ON/OFF autoritativo — el botón lo escribe, las probes lo leen directo (sin lock).
// Separado de cfg.enabled para que el toggle nunca dependa de un mutex que pueda fallar.
static PROC_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

#[cfg(target_os = "windows")]
const EXE_EXT: &str = ".exe";
#[cfg(not(target_os = "windows"))]
const EXE_EXT: &str = "";

// ─── Estructuras de datos ────────────────────────────────────────────────────

#[derive(Debug, Clone)]
struct Track {
    path: PathBuf,
    duration_secs: f64,
    title: String,
}

#[derive(Debug, Clone)]
struct Commercial {
    path: PathBuf,        // ruta local (puede ser vacía si es URL)
    url: Option<String>,  // URL de streaming http/https
    duration_secs: f64,
}

/// Detecta si una cadena es una URL de streaming (http:// o https://)
fn is_url(s: &str) -> bool {
    let s = s.trim();
    s.starts_with("http://") || s.starts_with("https://")
}

/// Valida dias activos: campo "1234567" donde 1=Lun ... 7=Dom.
fn valid_day_mask(mask: &str) -> bool {
    use chrono::Datelike;
    let dow = chrono::Local::now().weekday().number_from_monday();
    mask.trim().chars().any(|c| c.to_digit(10) == Some(dow))
}

/// Valida rango de fechas YYYYMMDD. Retorna true si hoy esta entre start y end (inclusive).
fn valid_date_range(start: &str, end: &str) -> bool {
    let today = chrono::Local::now().format("%Y%m%d").to_string();
    let s = start.trim().trim_end_matches('\r');
    let e = end.trim().trim_end_matches('\r');
    if (s.is_empty() || s == "0") && (e.is_empty() || e == "0") { return true; }
    let ok_start = s.is_empty() || s == "0" || today.as_str() >= s;
    let ok_end   = e.is_empty() || e == "0" || today.as_str() <= e;
    ok_start && ok_end
}

/// Parsea una linea de archivo .com / eventoslist / eventos-esperalist.
///
/// Formatos soportados:
///
/// 1. Formato .com con validacion de dias y fechas:
///      /ruta/audio.mp3|1234567|20260101|20260401
///      /ruta/carpeta/|1234567|20260101|20260401   <- carpeta: elige aleatorio
///
/// 2. Formato legado mplayer (streams):
///      -cache 300 -endpos 900 https://host:port/stream
///
/// 3. Formato nuevo stream: URL seguida de duracion en segundos:
///      https://host:port/stream 900
///
/// 4. Archivo local con duracion TAB:
///      /ruta/audio.mp3\t03:45.000
///
/// Retorna None si la linea no aplica hoy (dia o fecha fuera de rango).

/// Carga un archivo .gradio: si `at_index` es Some(idx) inserta en esa posición,
/// si es None reemplaza toda la playlist4.
fn apply_gradio_file(state: &SharedState, gradio_path: &Path, at_index: Option<usize>) {
    let playlist_path = dirs::home_dir().unwrap().join(".gradio/data/tmp/playlist4");
    let Ok(content) = fs::read_to_string(gradio_path) else {
        error!("apply_gradio_file: no se pudo leer {:?}", gradio_path);
        return;
    };
    let new_tracks: Vec<Track> = content
        .lines()
        .filter_map(|line| {
            let line = line.trim();
            if line.is_empty() { return None; }
            let (path_str, dur_str) = if let Some(pos) = line.find('\t') {
                (line[..pos].trim(), line[pos+1..].trim())
            } else {
                (line, "")
            };
            if path_str.is_empty() { return None; }
            let path = PathBuf::from(path_str);
            let title = path.file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("Audio")
                .to_string();
            let duration_secs: f64 = dur_str
                .parse().unwrap_or_else(|_| {
                    let parts: Vec<&str> = dur_str.splitn(2, ':').collect();
                    if parts.len() == 2 {
                        let m: f64 = parts[0].parse().unwrap_or(0.0);
                        let s: f64 = parts[1].trim_end_matches(".000").parse().unwrap_or(0.0);
                        m * 60.0 + s
                    } else { 0.0 }
                });
            Some(Track { path, title, duration_secs })
        })
        .collect();

    let mut st = state.lock().unwrap();
    match at_index {
        None => {
            // Reemplazar toda la playlist
            st.playlist = new_tracks;
            info!("Playlist reemplazada con {} temas desde {:?}", st.playlist.len(), gradio_path);
        }
        Some(idx) => {
            // Insertar desde la posición idx
            let insert_at = idx.min(st.playlist.len());
            for (i, track) in new_tracks.into_iter().enumerate() {
                st.playlist.insert(insert_at + i, track);
            }
            info!("Insertados temas desde {:?} en posición {}", gradio_path, insert_at);
        }
    }
    st.playlist_version = st.playlist_version.wrapping_add(1);
    save_playlist_to_file(&st.playlist, &playlist_path);
}

fn parse_commercial_line(line: &str) -> Option<Commercial> {
    let line = line.trim().trim_end_matches('\r').trim();
    if line.is_empty() || line.starts_with('#') { return None; }

    // -- Formato legado mplayer: -cache N -endpos N https://... --------------
    if line.contains("-endpos") {
        let mut dur_secs: f64 = 0.0;
        let mut url_found: Option<String> = None;
        let parts: Vec<&str> = line.split_whitespace().collect();
        let mut i = 0;
        while i < parts.len() {
            if parts[i] == "-endpos" {
                if let Some(v) = parts.get(i + 1) {
                    dur_secs = v.parse::<f64>().unwrap_or(0.0);
                    i += 2;
                    continue;
                }
            } else if is_url(parts[i]) {
                url_found = Some(parts[i].to_string());
            }
            i += 1;
        }
        if let Some(url) = url_found {
            return Some(Commercial { path: PathBuf::new(), url: Some(url), duration_secs: dur_secs });
        }
    }

    // -- Formato nuevo stream: https://... <duracion_segundos> ---------------
    if line.starts_with("http://") || line.starts_with("https://") {
        let mut parts = line.splitn(2, |c: char| c.is_whitespace());
        let url = parts.next().unwrap_or("").trim().to_string();
        let rest = parts.next().unwrap_or("").trim();
        let dur = rest.parse::<f64>().unwrap_or(0.0);
        return Some(Commercial { path: PathBuf::new(), url: Some(url), duration_secs: dur });
    }

    // -- Formato .com con metadatos: ruta|dias|inicio|fin --------------------
    if line.contains('|') {
        let parts: Vec<&str> = line.splitn(4, '|').collect();
        let file_part  = parts.get(0).unwrap_or(&"").trim();
        let days_mask  = parts.get(1).unwrap_or(&"").trim();
        let start_date = parts.get(2).unwrap_or(&"").trim();
        let end_date   = parts.get(3).unwrap_or(&"").trim().trim_end_matches('\r');

        if file_part.is_empty() { return None; }

        // Validar dia de semana (si mask es "0" o vacia = todos los dias)
        if !days_mask.is_empty() && days_mask != "0" && !valid_day_mask(days_mask) {
            return None;
        }

        // Validar rango de fechas
        if !valid_date_range(start_date, end_date) {
            return None;
        }

        // Normalizar ruta: quitar /* o / al final para que is_dir() funcione
        let clean_file = file_part
            .trim_end_matches('/')
            .trim_end_matches('*')
            .trim_end_matches('/');
        return Some(Commercial {
            path: PathBuf::from(clean_file),
            url: None,
            duration_secs: 0.0,
        });
    }

    // -- Archivo local con duracion TAB: /ruta/audio.mp3\tMM:SS.mmm ----------
    let (file_part, dur_part) = if let Some(pos) = line.find('\t') {
        (line[..pos].trim(), line[pos+1..].trim())
    } else {
        (line, "")
    };

    if file_part.is_empty() { return None; }

    let clean_path = sanitize_path(file_part);
    let duration_secs = if dur_part.is_empty() {
        0.0
    } else {
        dur_part.parse::<f64>().unwrap_or_else(|_| parse_duration_str(dur_part))
    };

    Some(Commercial { path: PathBuf::from(&clean_path), url: None, duration_secs })
}

#[derive(Debug, Clone, PartialEq)]
enum PlaybackState {
    Stopped,
    Playing,
    Paused,
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum ActiveDeck {
    A,
    B,
}

// ─── Estado compartido de la aplicación ──────────────────────────────────────

struct AppState {
    // Playlist y posición
    playlist: Vec<Track>,
    current_index: usize,

    // Título del track actualmente en reproducción (para la UI)
    current_title: String,
    // Versión de la playlist: se incrementa en cada modificación
    // para que el timer detecte cambios aunque el nº de filas sea igual
    playlist_version: u64,

    // Estado de cada deck (A=par, B=impar para crossfade)
    deck_a_state: PlaybackState,
    deck_b_state: PlaybackState,
    active_deck: ActiveDeck,

    // Pipelines de GStreamer
    pipeline_a: Option<gst::Pipeline>,
    pipeline_b: Option<gst::Pipeline>,
    // Pipeline para comerciales/jingles (visible en la UI mientras se reproduce)
    pipeline_comm: Option<gst::Pipeline>,
    pipeline_cue:      Option<gst::Pipeline>,  // preescucha CUE (tarjeta secundaria)
    pipeline_botonera: Option<gst::Pipeline>,  // botonera remota — corre sobre cualquier audio

    // Volumen (0.0 – 1.0)
    upvol: f64,
    downvol: f64,
    duck_active: bool,

    // Tiempo de fundido en segundos
    crossfade_secs: f64,

    // Comerciales
    #[allow(dead_code)]
    commercials: Vec<Commercial>,
    commercials_playing: bool,
    stop_after_track: bool,  // stop a fin de tema
    stopped_after_a: bool,   // deck A se detuvo por stop_after_track (para UI)
    stopped_after_b: bool,   // deck B se detuvo por stop_after_track (para UI)
    loop_deck_a: bool,           // loop infinito del deck A
    loop_deck_b: bool,           // loop infinito del deck B
    // Eventos emergentes
    eventos_playing: bool,   // true mientras se procesa eventoslist
    // Señal para interrumpir un streaming en curso (botón Siguiente)
    stream_skip: bool,
    // Señal para abortar el bloque de pautaje completo (drag a deck durante comerciales)
    abort_pautaje: bool,
    // Token de cancelación del crossfade en curso — se reemplaza en cada nuevo crossfade
    crossfade_cancel: Arc<AtomicBool>,
    // Evita lanzar el crossfade automático más de una vez por track
    crossfade_triggered: bool,
    // Nombre del audio reproduciéndose en pipeline_comm (para la UI)
    comm_title: String,
    // Track musical activo: ruta e instante de inicio (para log con tiempo real)
    current_track_path: PathBuf,
    current_track_start: std::time::Instant,
    station_name: String,
    pisador_track_count: u32,  // temas reproducidos desde último pisador
    vu_active: bool,             // VU meter activo
    vu_level_l: f64,             // nivel canal izquierdo (0.0-1.0 lineal, con decay visual)
    vu_level_r: f64,             // nivel canal derecho (con decay visual)
    vu_level_l_raw: f64,         // nivel crudo sin decay (para detección de silencio)
    vu_level_r_raw: f64,
    vu_last_level_msg: std::time::Instant, // última vez que llegó un mensaje level de GStreamer
    // Detector de silencio sostenido (audio atípico que no genera EOS)
    silence_secs: f64,
    silence_since_a: Option<std::time::Instant>,
    silence_since_b: Option<std::time::Instant>,
    // Detector de posición estancada (pipeline Playing pero pos no avanza → archivo pegado)
    stuck_pos_a: f64,
    stuck_since_a: Option<std::time::Instant>,
    stuck_pos_b: f64,
    stuck_since_b: Option<std::time::Instant>,
    // Marca la última vez que disparó el timer (para detectar suspensión del main loop en ARM/Wayland)
    last_timer_tick: std::time::Instant,
    // Procesador DSP multiband (None = desactivado)
    dsp: Option<processor::SharedDsp>,
}

impl AppState {
    fn new() -> Self {
        Self {
            playlist: Vec::new(),
            current_index: 0,
            deck_a_state: PlaybackState::Stopped,
            deck_b_state: PlaybackState::Stopped,
            active_deck: ActiveDeck::A,
            pipeline_a: None,
            pipeline_b: None,
            pipeline_comm: None,
            pipeline_cue: None,
            pipeline_botonera: None,
            current_title: String::new(),
            playlist_version: 0,
            upvol: 1.0,
            downvol: 0.3,
            duck_active: false,
            crossfade_secs: 3.0,
            commercials: Vec::new(),
            commercials_playing: false,
            stop_after_track: false,
            stopped_after_a: false,
            stopped_after_b: false,
            loop_deck_a: false,
            loop_deck_b: false,
            eventos_playing: false,
            stream_skip: false,
            abort_pautaje: false,
            crossfade_cancel: Arc::new(AtomicBool::new(false)),
            crossfade_triggered: false,
            comm_title: String::new(),
            current_track_path: PathBuf::new(),
            current_track_start: std::time::Instant::now(),
            station_name: String::new(),
            pisador_track_count: 0,
            vu_active: true,
            vu_level_l: 0.0,
            vu_level_r: 0.0,
            vu_level_l_raw: -100.0,   // dB crudo; -100 = silencio absoluto
            vu_level_r_raw: -100.0,
            vu_last_level_msg: std::time::Instant::now(),
            silence_secs: 5.0,
            silence_since_a: None,
            silence_since_b: None,
            stuck_pos_a: -1.0,
            stuck_since_a: None,
            stuck_pos_b: -1.0,
            stuck_since_b: None,
            last_timer_tick: std::time::Instant::now(),
            dsp: None,
        }
    }
}

type SharedState = Arc<Mutex<AppState>>;

// ─── Helpers de archivos ──────────────────────────────────────────────────────

fn home_dir() -> PathBuf {
    dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"))
}

fn read_f64_from_file(path: &Path) -> f64 {
    fs::read_to_string(path)
        .ok()
        .and_then(|s| s.trim().parse::<f64>().ok())
        .unwrap_or(100.0)
}

fn contar_lineas_archivo(path: &Path) -> usize {
    fs::read_to_string(path)
        .unwrap_or_default()
        .lines()
        .filter(|l| !l.trim().is_empty())
        .count()
}

fn parse_duration_str(s: &str) -> f64 {
    // Formato: MM:SS.mmm  o  HH:MM:SS
    let s = s.trim();
    let parts: Vec<&str> = s.split(':').collect();
    match parts.len() {
        2 => {
            // MM:SS.mmm
            let mins: f64 = parts[0].parse().unwrap_or(0.0);
            let secs: f64 = parts[1].parse().unwrap_or(0.0);
            mins * 60.0 + secs
        }
        3 => {
            // HH:MM:SS or 00:MM:SS.mmm
            let h: f64 = parts[0].parse().unwrap_or(0.0);
            let m: f64 = parts[1].parse().unwrap_or(0.0);
            let sec: f64 = parts[2].parse().unwrap_or(0.0);
            h * 3600.0 + m * 60.0 + sec
        }
        _ => 0.0,
    }
}

fn load_playlist(path: &Path) -> Vec<Track> {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            warn!("No se pudo leer playlist {}: {}", path.display(), e);
            return Vec::new();
        }
    };

    content
        .lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|line| {
            let parts: Vec<&str> = line.splitn(2, '\t').collect();
            if parts.len() < 2 {
                // Try splitting by multiple spaces
                let mut it = line.rsplitn(2, "  ");
                let dur_s = it.next()?.trim();
                let file_path = it.next()?.trim();
                let _title = Path::new(file_path)
                    .file_stem()
                    .and_then(|s| s.to_str())
                    .unwrap_or(file_path)
                    .to_string();
                let file_path = sanitize_path(file_path);
                let title = Path::new(&file_path)
                    .file_stem()
                    .and_then(|s| s.to_str())
                    .unwrap_or(&file_path)
                    .to_string();
                Some(Track {
                    path: PathBuf::from(&file_path),
                    duration_secs: parse_duration_str(dur_s),
                    title,
                })
            } else {
                let raw_path = parts[0].trim().trim_end_matches('\r');
                let dur_s    = parts[1].trim().trim_end_matches('\r');
                // Ignorar líneas con ruta vacía
                if raw_path.is_empty() { return None; }
                // Detectar ruta duplicada: /a/b.mp3/a/b.mp3
                let file_path = sanitize_path(raw_path);
                let title = Path::new(&file_path)
                    .file_stem()
                    .and_then(|s| s.to_str())
                    .unwrap_or(&file_path)
                    .to_string();
                Some(Track {
                    path: PathBuf::from(&file_path),
                    duration_secs: parse_duration_str(dur_s),
                    title,
                })
            }
        })
        .collect()
}

/// Limpia una ruta de archivo: quita \r y detecta rutas duplicadas
/// del tipo "/ruta/x.mp3/ruta/x.mp3" devolviendo solo la primera mitad.
fn sanitize_path(raw: &str) -> String {
    let raw = raw.trim().trim_end_matches('\r');
    for ext in &["mp3", "MP3", "wav", "WAV", "flac", "FLAC", "m4a", "M4A", "ogg", "OGG"] {
        let needle = format!(".{}/", ext);
        if let Some(p) = raw.find(&needle) {
            return raw[..p + needle.len() - 1].to_string(); // hasta ".ext" sin el "/"
        }
    }
    raw.to_string()
}

/// Guarda la playlist actual (desde AppState) al archivo playlist4.
/// Se llama cada vez que la lista se modifica por drag, reorden, o doble click.
fn save_playlist_to_file(playlist: &[Track], path: &Path) {
    use std::io::Write;
    let content: String = playlist
        .iter()
        .map(|t| format!("{}\t{}\n", sanitize_path(&t.path.to_string_lossy()), secs_to_display(t.duration_secs)))
        .collect();
    if let Ok(mut f) = fs::OpenOptions::new()
        .write(true).create(true).truncate(true)
        .open(path)
    {
        let _ = f.write_all(content.as_bytes());
    }
}

/// Inserta un archivo de audio en la playlist en la posición dada.
/// Detecta la duración desde GStreamer (discoverer) o la deja en 0.
fn make_track_from_path(path: PathBuf) -> Track {
    let title = path.file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("desconocido")
        .to_string();
    // Intentar obtener duración con GStreamer discoverer
    let duration_secs = {
        let uri = format!("file://{}", path.display());
        if let Ok(d) = gst_pbutils::Discoverer::new(gst::ClockTime::from_seconds(5)) {
            if let Ok(info) = d.discover_uri(&uri) {
                info.duration()
                    .map(|t| t.seconds() as f64)
                    .unwrap_or(0.0)
            } else { 0.0 }
        } else { 0.0 }
    };
    Track { path, duration_secs, title }
}

/// Inserta una lista de paths en la playlist en `insert_at`, validando cada
/// archivo con symphonia para descartar audios corruptos antes de
/// agregarlos. Devuelve cuántos se insertaron efectivamente.
///
/// Centraliza la validación para todas las vías de entrada del usuario:
/// drag-drop al listbox/scroll/frame de la cola, drop al catch-all de
/// lists_row, y URIs llegadas como text/uri-list.
fn insert_paths_validated(
    paths: Vec<PathBuf>,
    insert_at: usize,
    st: &mut AppState,
    playlist_path: &Path,
) -> usize {
    let mut inserted = 0;
    for path in paths {
        if !path.exists() { continue; }
        let ext = path.extension()
            .and_then(|e| e.to_str())
            .unwrap_or("")
            .to_lowercase();
        if !matches!(ext.as_str(), "mp3"|"wav"|"ogg"|"flac"|"aac"|"m4a") {
            continue;
        }
        if !grpautaje::audio_probe::is_playable(&path) {
            warn!("Audio inválido descartado al insertar: {}", path.display());
            continue;
        }
        let track = make_track_from_path(path);
        st.playlist.insert(insert_at + inserted, track);
        inserted += 1;
    }
    if inserted > 0 {
        save_playlist_to_file(&st.playlist, playlist_path);
        st.playlist_version = st.playlist_version.wrapping_add(1);
    }
    inserted
}

/// Elimina la primera línea no vacía de un archivo de texto plano.
/// Se llama justo antes de reproducir cada tema/comercial para que
/// la lista siempre refleje lo que FALTA reproducir.
fn remove_first_line_from_file(path: &Path) {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => return,
    };
    // Saltar la primera línea no vacía y reconstruir el resto
    let new_content: Vec<&str> = content
        .lines()
        .skip_while(|l| l.trim().is_empty())
        .skip(1)
        .collect();
    let result = new_content.join("\n");
    let result = if result.is_empty() { String::new() } else { format!("{}\n", result) };
    let _ = fs::write(path, result);
}

// ─── Funciones de reporte y control histórico ────────────────────────────────

/// Escribe una línea en el reporte diario de parrilla musical.
/// Formato: "YYYY-MM-DD,HH:MM:SS,/ruta/audio.mp3,duracion,seg,parrilla"
fn log_parrilla(path: &Path, duration_secs: f64) {
    // duration_secs = tiempo REAL al aire (pipeline_position al momento de cerrar)
    let now = Local::now();
    let home = home_dir();
    let reporte_dir = home.join(".gradio/data/reporte");
    let _ = fs::create_dir_all(&reporte_dir);
    let report_path = reporte_dir.join(format!("GR6-parrilla-{}.txt", now.format("%Y-%m-%d")));
    let line = format!(
        "{},{},{},{},seg,parrilla\n",
        now.format("%Y-%m-%d"),
        now.format("%H:%M:%S"),
        path.display(),
        duration_secs as u64,
    );
    use std::io::Write;
    if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&report_path) {
        let _ = f.write_all(line.as_bytes());
    }
    info!("Parrilla log: {}", line.trim());
}

/// Escribe una línea en el reporte diario de comerciales.
/// Formato: "YYYY-MM-DD,HH:MM:SS,/ruta/comercial.mp3,duracion,seg,comercial"
fn log_comercial(path: &Path, duration_secs: f64) {
    let now = Local::now();
    let home = home_dir();
    let reporte_dir = home.join(".gradio/data/reporte");
    let _ = fs::create_dir_all(&reporte_dir);

    let filename = format!(
        "GR6-comercial-{}.txt",
        now.format("%Y-%m-%d")
    );
    let report_path = reporte_dir.join(filename);

    let line = format!(
        "{},{},{},{},seg,comercial\n",
        now.format("%Y-%m-%d"),
        now.format("%H:%M:%S"),
        path.display(),
        duration_secs as u64,
    );

    use std::io::Write;
    if let Ok(mut f) = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&report_path)
    {
        let _ = f.write_all(line.as_bytes());
    }
    info!("Comercial log: {}", line.trim());
}

/// Registra el nombre del archivo en .historico.mus dentro de su carpeta.
/// Cada emisión se registra siempre, permitiendo auditar repeticiones.
fn log_historico(track_path: &Path) {
    let dir = match track_path.parent() {
        Some(d) => d,
        None => return,
    };
    let filename = match track_path.file_name().and_then(|n| n.to_str()) {
        Some(n) => n.to_string(),
        None => return,
    };
    let hist_path = dir.join(".historico.mus");

    use std::io::Write;
    if let Ok(mut f) = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&hist_path)
    {
        let _ = f.write_all(format!("{}\n", filename).as_bytes());
    }
    info!("Histórico: {} → {}", filename, hist_path.display());
}

/// Carga los eventos en espera desde el archivo eventos-esperalist.
/// Reutiliza la estructura Commercial (ruta + duración).
fn load_eventos(path: &Path) -> Vec<Commercial> {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            info!("load_eventos: no se pudo leer {:?}: {}", path, e);
            return Vec::new();
        }
    };
    let result: Vec<Commercial> = content
        .lines()
        .filter_map(|line| parse_commercial_line(line))
        .collect();
    info!("load_eventos({:?}): {} items cargados", path, result.len());
    result
}

/// Escribe una línea en el reporte diario de eventos en espera.
fn log_evento(path: &Path, duration_secs: f64) {
    let now = Local::now();
    let home = home_dir();
    let reporte_dir = home.join(".gradio/data/reporte");
    let _ = fs::create_dir_all(&reporte_dir);
    let report_path = reporte_dir.join(format!("GR6-evento-{}.txt", now.format("%Y-%m-%d")));
    let line = format!(
        "{},{},{},{},seg,evento
",
        now.format("%Y-%m-%d"),
        now.format("%H:%M:%S"),
        path.display(),
        duration_secs as u64,
    );
    use std::io::Write;
    if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&report_path) {
        let _ = f.write_all(line.as_bytes());
    }
    info!("Evento log: {}", line.trim());
}

fn load_commercials(path: &Path) -> Vec<Commercial> {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            info!("load_commercials: no se pudo leer {:?}: {}", path, e);
            return Vec::new();
        }
    };
    let result: Vec<Commercial> = content
        .lines()
        .filter_map(|line| parse_commercial_line(line))
        .collect();
    info!("load_commercials({:?}): {} items cargados", path, result.len());
    result
}

fn random_file_from_dir(dir: &Path) -> Option<PathBuf> {
    let entries: Vec<PathBuf> = fs::read_dir(dir)
        .ok()?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.is_file()
                && matches!(
                    p.extension().and_then(|e| e.to_str()),
                    Some("mp3") | Some("wav") | Some("ogg") | Some("flac")
                )
        })
        .collect();

    let mut rng = rand::thread_rng();
    entries.choose(&mut rng).cloned()
}


/// Para una línea del archivo de cola, devuelve el texto a mostrar en la lista.
/// Si es carpeta (termina en /* o / o es directorio): "nombre-random (MM:SS)"
/// Si es archivo: nombre sin extensión
/// Si es URL: la URL
/// Extrae la ruta de una línea de comercialeslist4/eventoslist/eventos-esperalist.
/// Soporta `path|day-mask|start|end` y `path\tdur`.
fn parse_path_from_queue_line(raw: &str) -> &str {
    let p1 = raw.splitn(2, '\t').next().unwrap_or(raw).trim();
    p1.split('|').next().unwrap_or(p1).trim()
}

/// Intercambia dos líneas no vacías por índice (sólo cuenta no vacías).
/// Preserva las líneas vacías intermedias en su posición original.
fn swap_lines_in_file(path: &std::path::Path, i: usize, j: usize) {
    let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return };
    let mut lines: Vec<String> = content.lines().map(String::from).collect();
    let non_empty: Vec<usize> = lines.iter().enumerate()
        .filter(|(_, l)| !l.trim().is_empty())
        .map(|(idx, _)| idx).collect();
    if i >= non_empty.len() || j >= non_empty.len() || i == j { return; }
    let real_i = non_empty[i];
    let real_j = non_empty[j];
    lines.swap(real_i, real_j);
    let mut out = lines.join("\n");
    if !out.ends_with('\n') { out.push('\n'); }
    let _ = fs::write(path, out);
}

/// Decodifica percent-encoding de URIs (file:// GStreamer): %20 → ' ', etc.
/// Maneja UTF-8 multi-byte correctamente (ej. %C3%A9 → é).
fn percent_decode(s: &str) -> String {
    let chars: Vec<u8> = s.bytes().collect();
    let mut bytes: Vec<u8> = Vec::with_capacity(chars.len());
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == b'%' && i + 2 < chars.len() {
            if let Ok(hex) = std::str::from_utf8(&chars[i+1..i+3]) {
                if let Ok(byte) = u8::from_str_radix(hex, 16) {
                    bytes.push(byte);
                    i += 3;
                    continue;
                }
            }
        }
        bytes.push(chars[i]);
        i += 1;
    }
    String::from_utf8_lossy(&bytes).into_owned()
}

/// Duración en segundos para una línea de cola. 0.0 = stream/no disponible.
/// Usa cache thread-local para no relanzar ffprobe en cada rebuild.
fn duration_for_queue_line(raw: &str) -> f64 {
    use std::cell::RefCell;
    use std::collections::HashMap;
    thread_local! {
        static DUR_CACHE: RefCell<HashMap<String, f64>> = RefCell::new(HashMap::new());
    }
    let path_part = parse_path_from_queue_line(raw);
    if path_part.starts_with("http://") || path_part.starts_with("https://") {
        return 0.0; // stream — sin duración
    }
    // Si la línea trae duración explícita en formato `\tHH:MM:SS` o `\tMM:SS.mmm`, usarla
    if let Some(rest) = raw.split('\t').nth(1) {
        let parts: Vec<&str> = rest.trim().splitn(3, ':').collect();
        let secs: f64 = match parts.as_slice() {
            [h, m, s] => h.parse::<f64>().unwrap_or(0.0) * 3600.0
                       + m.parse::<f64>().unwrap_or(0.0) * 60.0
                       + s.trim_end_matches(".000").parse::<f64>().unwrap_or(0.0),
            [m, s]    => m.parse::<f64>().unwrap_or(0.0) * 60.0
                       + s.trim_end_matches(".000").parse::<f64>().unwrap_or(0.0),
            _ => 0.0,
        };
        if secs > 0.0 { return secs; }
    }
    let key = path_part.to_string();
    if let Some(d) = DUR_CACHE.with(|c| c.borrow().get(&key).copied()) {
        return d;
    }
    let normalized = path_part.trim_end_matches('/').trim_end_matches('*').trim_end_matches('/');
    let p = std::path::Path::new(normalized);
    let dur = if p.is_dir() {
        first_audio_duration_in_dir(p).unwrap_or(0.0)
    } else if p.is_file() {
        std::process::Command::new("ffprobe")
            .args(["-v", "error", "-show_entries", "format=duration",
                   "-of", "default=noprint_wrappers=1:nokey=1", normalized])
            .output().ok()
            .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok())
            .unwrap_or(0.0)
    } else {
        0.0
    };
    DUR_CACHE.with(|c| c.borrow_mut().insert(key, dur));
    dur
}

fn display_name_for_queue_line(raw: &str) -> String {
    let path_part = raw.splitn(2, '\t').next().unwrap_or(raw).trim();
    let path_part = path_part.split('|').next().unwrap_or(path_part).trim();

    if path_part.starts_with("http://") || path_part.starts_with("https://") {
        return path_part.to_string();
    }

    // Normalizar: quitar /* y / al final
    let normalized = path_part
        .trim_end_matches('/')
        .trim_end_matches('*')
        .trim_end_matches('/');

    let p = std::path::Path::new(normalized);

    if p.is_dir() {
        // Nombre de la carpeta + "-random" + duración del primer audio
        let folder_name = p.file_name()
            .and_then(|s| s.to_str())
            .unwrap_or(normalized);
        let dur_str = first_audio_duration_in_dir(p)
            .map(|d| format!(" ({:02}:{:02})", d as u64 / 60, d as u64 % 60))
            .unwrap_or_default();
        return format!("{}-random{}", folder_name, dur_str);
    }

    // Archivo normal
    p.file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or(path_part)
        .to_string()
}

/// Obtiene la duración del primer archivo de audio en un directorio.
fn first_audio_duration_in_dir(dir: &std::path::Path) -> Option<f64> {
    let mut entries: Vec<std::path::PathBuf> = fs::read_dir(dir).ok()?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.is_file() && matches!(
            p.extension().and_then(|x| x.to_str()),
            Some("mp3") | Some("wav") | Some("ogg") | Some("flac") | Some("m4a")
        ))
        .collect();
    entries.sort();
    let first = entries.into_iter().next()?;
    let out = std::process::Command::new("ffprobe")
        .args(["-v", "error", "-show_entries", "format=duration",
               "-of", "default=noprint_wrappers=1:nokey=1",
               first.to_str()?])
        .output().ok()?;
    String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok()
}

fn secs_to_display(secs: f64) -> String {
    let total = secs as u64;
    let h = total / 3600;
    let m = (total % 3600) / 60;
    let s = total % 60;
    if h > 0 {
        format!("{:02}:{:02}:{:02}", h, m, s)
    } else {
        format!("{:02}:{:02}", m, s)
    }
}

// ─── Ventana de configuración del procesador DSP ─────────────────────────────

fn abrir_ventana_procesador(parent: &ApplicationWindow, state: SharedState) {
    use std::rc::Rc;
    use std::cell::RefCell;
    use std::sync::Arc;
    use processor::ui::band_strip::BandStrip;

    // Obtener config actual del DspProcessor.
    // Siempre leemos del global PROC_DSP (tiene la config más reciente en memoria
    // independientemente de si el procesador está ON u OFF). Fallback a disco.
    let cfg_arc: Arc<Mutex<processor::ProcessorConfig>> = {
        if let Ok(g) = proc_dsp_global().try_lock() {
            if let Some(ref dsp) = *g {
                if let Ok(d) = dsp.try_lock() {
                    Arc::new(Mutex::new(d.cfg.clone()))
                } else {
                    Arc::new(Mutex::new(processor::ProcessorConfig::cargar()))
                }
            } else {
                Arc::new(Mutex::new(processor::ProcessorConfig::cargar()))
            }
        } else {
            Arc::new(Mutex::new(processor::ProcessorConfig::cargar()))
        }
    };

    // Función que aplica cambios al DspProcessor vivo
    // El lock se sostiene el mínimo tiempo posible (solo para clonar la config).
    let apply: Rc<dyn Fn()> = {
        let cfg_arc = cfg_arc.clone();
        let state = state.clone();
        Rc::new(move || {
            // 1. Clonar config con lock mínimo
            let mut cfg = match cfg_arc.lock() {
                Ok(c) => c.clone(),
                Err(_) => return,
            }; // lock liberado aquí
            // 2. Sincronizar cfg.enabled con PROC_ENABLED (fuente autoritativa).
            //    La ventana de config nunca controla enabled — ese bit pertenece al botón.
            cfg.enabled = PROC_ENABLED.load(std::sync::atomic::Ordering::Relaxed);
            cfg.guardar();
            // Señal a todos los pipelines activos para que recarguen la config
            PROC_CFG_VER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            // Actualizar el DSP compartido
            if let Ok(g) = proc_dsp_global().try_lock() {
                if let Some(ref dsp) = *g {
                    if let Ok(mut d) = dsp.try_lock() {
                        d.update_config(cfg.clone());
                    }
                }
            }
            // También actualizar AppState.dsp por consistencia
            if let Ok(st) = state.try_lock() {
                if let Some(ref dsp) = st.dsp {
                    if let Ok(mut d) = dsp.try_lock() {
                        d.update_config(cfg);
                    }
                }
            }
        })
    };

    // CSS del procesador
    let css_str = concat!(
        "window { background-color: #0e0e12; }",
        ".rack-top { background-color:#16161c; border-bottom:2px solid #2a2a3a; padding:5px 10px; min-height:60px; }",
        ".rack-title { font-family:Monospace; font-size:13px; font-weight:bold; color:#e8830a; letter-spacing:3px; }",
        ".rack-subtitle { font-family:Monospace; font-size:7px; color:#3a3a50; letter-spacing:2px; }",
        ".rack-center { background-color:#181820; border:1px solid #252535; border-radius:4px; padding:4px; }",
        ".band-strip { background-color:#1e1e28; border:1px solid #2a2a38; border-radius:3px; padding:4px 3px; }",
        ".band-num { font-family:Monospace; font-size:11px; font-weight:bold; color:#e8830a; }",
        ".band-freq { font-family:Monospace; font-size:7px; color:#3a5060; }",
        ".meter-lbl { font-family:Monospace; font-size:7px; color:#344050; min-width:16px; }",
        ".band-check { font-family:Monospace; font-size:8px; color:#607080; }",
        ".side-panel { background-color:#14141c; border:1px solid #222230; border-radius:3px; padding:6px 5px; min-width:92px; }",
        ".side-lbl { font-family:Monospace; font-size:8px; color:#445060; }",
        ".io-lbl { font-family:Monospace; font-size:9px; color:#e8830a; }",
        "separator { background-color:#222232; margin:2px 0; min-height:1px; }",
        ".btn-load { font-family:Monospace; font-size:9px; font-weight:bold; color:#20c060; background:#0e1e0e; border:1px solid #185028; border-radius:3px; padding:2px 8px; }",
        ".btn-save { font-family:Monospace; font-size:9px; font-weight:bold; color:#2090d0; background:#0e1420; border:1px solid #183048; border-radius:3px; padding:2px 8px; }",
    );
    let css_prov = CssProvider::new();
    css_prov.load_from_data(css_str);

    let win = gtk4::Window::builder()
        .title(i18n::tr("win.processor"))
        .transient_for(parent)
        .default_width(1100)
        .default_height(560)
        .build();

    gtk4::style_context_add_provider_for_display(
        &gtk4::prelude::WidgetExt::display(&win),
        &css_prov,
        STYLE_PROVIDER_PRIORITY_APPLICATION,
    );

    let root = GtkBox::new(Orientation::Vertical, 4);
    root.set_margin_start(6); root.set_margin_end(6);
    root.set_margin_top(4);   root.set_margin_bottom(6);

    // ── Sección PRESETS ──────────────────────────────────────────────────────
    let presets_list = Rc::new(RefCell::new(processor::presets::all_presets()));
    let combo_preset = gtk4::ComboBoxText::new();
    for p in presets_list.borrow().iter() { combo_preset.append(Some(&p.name), &p.name); }
    // Restaurar el último preset usado
    {
        let last = cfg_arc.lock().unwrap().last_preset.clone();
        if last.is_empty() || !combo_preset.set_active_id(Some(&last)) {
            combo_preset.set_active(Some(0));
        }
    }

    let btn_load = Button::with_label("▶ LOAD");
    btn_load.set_css_classes(&["btn-load"]);
    let btn_save = Button::with_label("● SAVE");
    btn_save.set_css_classes(&["btn-save"]);

    let preset_row = GtkBox::new(Orientation::Horizontal, 6);
    preset_row.set_margin_bottom(4);
    preset_row.append(&Label::builder().label(i18n::tr("preset.label")).css_classes(["side-lbl"]).build());
    preset_row.append(&combo_preset);
    preset_row.append(&btn_load);
    preset_row.append(&btn_save);
    root.append(&preset_row);

    // Obtener bandas iniciales
    let (nb_init, freqs_init) = {
        let c = cfg_arc.lock().unwrap();
        (c.num_bands.clamp(2, 6), c.crossover_freqs)
    };

    let freq_labels = processor::ui::band_freq_labels(&freqs_init);

    // Arcos locales para los VU meters de la ventana de configuración.
    // El timer los sincroniza desde el DspProcessor cada 33 ms.
    let meter_arcs: Vec<(Arc<processor::AtomicF32>, Arc<processor::AtomicF32>)> =
        (0..6).map(|_| (
            Arc::new(processor::AtomicF32::new(0.0)),
            Arc::new(processor::AtomicF32::new(0.0)),
        )).collect();

    let strips: Vec<BandStrip> = (0..6).map(|b| {
        BandStrip::new(b, &freq_labels[b], meter_arcs[b].0.clone(), meter_arcs[b].1.clone())
    }).collect();

    // Conectar knobs → cfg → DSP
    for (b, strip) in strips.iter().enumerate() {
        { let ca = cfg_arc.clone(); let ap = apply.clone();
          strip.threshold.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].threshold_db = v; } ap(); }); }
        { let ca = cfg_arc.clone(); let ap = apply.clone();
          strip.ratio.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].ratio = v; } ap(); }); }
        { let ca = cfg_arc.clone(); let ap = apply.clone();
          strip.attack.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].attack_ms = v; } ap(); }); }
        { let ca = cfg_arc.clone(); let ap = apply.clone();
          strip.release.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].release_ms = v; } ap(); }); }
        { let ca = cfg_arc.clone(); let ap = apply.clone();
          strip.makeup.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].makeup_db = v; } ap(); }); }
        { let ca = cfg_arc.clone(); let ap = apply.clone();
          strip.clip_lvl.connect_changed(move |v| { if let Ok(mut c) = ca.lock() { c.bands[b].clip_level_db = v; } ap(); }); }
        { let ca = cfg_arc.clone(); let ap = apply.clone();
          strip.clip_en.connect_toggled(move |cb| { if let Ok(mut c) = ca.lock() { c.bands[b].clip_enable = cb.is_active(); } ap(); }); }
        { let ca = cfg_arc.clone(); let ap = apply.clone();
          strip.enabled.connect_toggled(move |cb| { if let Ok(mut c) = ca.lock() { c.bands[b].enabled = cb.is_active(); } ap(); }); }
    }

    // Inicializar valores de knobs desde config
    {
        let c = cfg_arc.lock().unwrap();
        for (b, strip) in strips.iter().enumerate() {
            let bd = &c.bands[b];
            strip.threshold.set_value(bd.threshold_db);
            strip.ratio    .set_value(bd.ratio);
            strip.attack   .set_value(bd.attack_ms);
            strip.release  .set_value(bd.release_ms);
            strip.makeup   .set_value(bd.makeup_db);
            strip.clip_lvl .set_value(bd.clip_level_db);
            strip.clip_en  .set_active(bd.clip_enable);
            strip.enabled  .set_active(bd.enabled);
            strip.set_active(b < nb_init);
        }
    }

    let bands_box = GtkBox::builder()
        .orientation(Orientation::Horizontal).spacing(3)
        .hexpand(true).vexpand(true)
        .css_classes(["rack-center"]).build();
    for s in &strips { bands_box.append(&s.widget); }

    // SpinButton de bandas
    let sp_bands = gtk4::SpinButton::with_range(2.0, 6.0, 1.0);
    sp_bands.set_value(nb_init as f64);
    {
        let ca = cfg_arc.clone(); let ap = apply.clone();
        let strips_c = strips.clone();
        sp_bands.connect_value_changed(move |s| {
            let nb = s.value() as usize;
            if let Ok(mut c) = ca.lock() { c.num_bands = nb; }
            ap();
            for (i, strip) in strips_c.iter().enumerate() { strip.set_active(i < nb); }
        });
    }

    // Sliders IN/OUT/LIMITER
    let sl_in = gtk4::Scale::with_range(Orientation::Horizontal, -12.0, 12.0, 0.5);
    sl_in.set_value(cfg_arc.lock().unwrap().input_gain_db as f64);
    sl_in.set_width_request(80);
    { let ca = cfg_arc.clone(); let ap = apply.clone();
      sl_in.connect_value_changed(move |s| { if let Ok(mut c) = ca.lock() { c.input_gain_db = s.value() as f32; } ap(); }); }

    let sl_out = gtk4::Scale::with_range(Orientation::Horizontal, -12.0, 12.0, 0.5);
    sl_out.set_value(cfg_arc.lock().unwrap().output_gain_db as f64);
    sl_out.set_width_request(80);
    { let ca = cfg_arc.clone(); let ap = apply.clone();
      sl_out.connect_value_changed(move |s| { if let Ok(mut c) = ca.lock() { c.output_gain_db = s.value() as f32; } ap(); }); }

    let sl_lim = gtk4::Scale::with_range(Orientation::Horizontal, -6.0, 0.0, 0.1);
    sl_lim.set_value(cfg_arc.lock().unwrap().limiter_threshold as f64);
    sl_lim.set_width_request(80);
    { let ca = cfg_arc.clone(); let ap = apply.clone();
      sl_lim.connect_value_changed(move |s| { if let Ok(mut c) = ca.lock() { c.limiter_threshold = s.value() as f32; } ap(); }); }

    // Panel izquierdo: bandas + IN gain
    let side_l = GtkBox::builder().orientation(Orientation::Vertical).spacing(6)
        .css_classes(["side-panel"]).vexpand(true).build();
    side_l.append(&Label::new(Some("BANDS")));
    side_l.append(&sp_bands);
    side_l.append(&gtk4::Separator::new(Orientation::Horizontal));
    side_l.append(&Label::new(Some("IN GAIN")));
    side_l.append(&sl_in);

    // Panel derecho: OUT gain + limitador
    let side_r = GtkBox::builder().orientation(Orientation::Vertical).spacing(6)
        .css_classes(["side-panel"]).vexpand(true).build();
    side_r.append(&Label::new(Some("OUT GAIN")));
    side_r.append(&sl_out);
    side_r.append(&gtk4::Separator::new(Orientation::Horizontal));
    side_r.append(&Label::new(Some("LIMITER")));
    side_r.append(&sl_lim);

    let center = GtkBox::builder()
        .orientation(Orientation::Horizontal).spacing(4).vexpand(true).build();
    center.append(&side_l);
    center.append(&bands_box);
    center.append(&side_r);
    root.append(&center);

    // ── LOAD preset ──────────────────────────────────────────────────────────
    {
        let ca = cfg_arc.clone();
        let ap = apply.clone();
        let strips_c = strips.clone();
        let sp_c = sp_bands.clone();
        let pr = presets_list.clone();
        let cb = combo_preset.clone();
        btn_load.connect_clicked(move |_| {
            let name = match cb.active_text() { Some(n) => n.to_string(), None => return };
            let found = pr.borrow().iter().find(|p| p.name == name).cloned();
            if let Some(preset) = found {
                // Aplicar preset + guardar nombre como último usado
                { if let Ok(mut c) = ca.lock() {
                    preset.apply(&mut c);
                    c.last_preset = name.clone();
                } }
                // apply() clona la config internamente → sin lock al actualizar DSP
                ap();
                // Extraer valores con el lock, luego liberar ANTES de actualizar GTK.
                // Crítico: set_active/set_value en CheckButton y SpinButton emiten señales
                // síncronas que intentan bloquear ca de nuevo → deadlock si lo mantenemos.
                let (bands_snap, nb_snap) = match ca.lock() {
                    Ok(c) => (c.bands.clone(), c.num_bands),
                    Err(_) => return,
                }; // lock liberado aquí
                for (b, strip) in strips_c.iter().enumerate() {
                    let bd = &bands_snap[b];
                    strip.threshold.set_value(bd.threshold_db);
                    strip.ratio    .set_value(bd.ratio);
                    strip.attack   .set_value(bd.attack_ms);
                    strip.release  .set_value(bd.release_ms);
                    strip.makeup   .set_value(bd.makeup_db);
                    strip.clip_lvl .set_value(bd.clip_level_db);
                    strip.clip_en  .set_active(bd.clip_enable);
                    strip.enabled  .set_active(bd.enabled);
                    strip.set_active(b < nb_snap);
                }
                sp_c.set_value(nb_snap as f64); // dispara callback; ca ya está libre
            }
        });
    }

    // ── SAVE preset ───────────────────────────────────────────────────────────
    {
        let ca = cfg_arc.clone();
        let pr = presets_list.clone();
        let cb2 = combo_preset.clone();
        let win_c = win.clone();
        btn_save.connect_clicked(move |_| {
            let dialog = gtk4::Window::builder()
                .transient_for(&win_c)
                .modal(true)
                .title(i18n::tr("preset.save_dialog"))
                .default_width(300)
                .build();
            let vbox = GtkBox::new(Orientation::Vertical, 10);
            vbox.set_margin_start(16); vbox.set_margin_end(16);
            vbox.set_margin_top(14);   vbox.set_margin_bottom(14);
            vbox.append(&Label::builder().label(i18n::tr("preset.name_label")).halign(gtk4::Align::Start).build());
            let entry = gtk4::Entry::builder().text(i18n::tr("preset.default_name")).hexpand(true).build();
            vbox.append(&entry);
            let btn_row2 = GtkBox::new(Orientation::Horizontal, 6);
            btn_row2.set_halign(gtk4::Align::End);
            let btn_cancel = Button::with_label(i18n::tr("btn.cancel"));
            let btn_ok = Button::with_label(i18n::tr("btn.save"));
            btn_row2.append(&btn_cancel);
            btn_row2.append(&btn_ok);
            vbox.append(&btn_row2);
            dialog.set_child(Some(&vbox));

            let d1 = dialog.clone();
            btn_cancel.connect_clicked(move |_| d1.destroy());

            let d2 = dialog.clone();
            let ca2 = ca.clone();
            let pr2 = pr.clone();
            let cb3 = cb2.clone();
            btn_ok.connect_clicked(move |_| {
                let name = entry.text().to_string().trim().to_string();
                if name.is_empty() { return; }
                if let Ok(c) = ca2.lock() {
                    let preset = processor::presets::Preset::from_config(&name, &c);
                    if let Err(e) = preset.save() {
                        log::error!("Error guardando preset: {}", e);
                    }
                    let exists = pr2.borrow().iter().any(|p| p.name == name);
                    if !exists { cb3.append(Some(&name), &name); }
                    pr2.borrow_mut().retain(|p| p.name != name);
                    pr2.borrow_mut().push(preset);
                    cb3.set_active_id(Some(&name));
                }
                d2.destroy();
            });
            dialog.present();
        });
    }

    // Timer 30 fps: sincroniza VU metros desde DspProcessor y refresca DrawingAreas.
    // Se detiene automáticamente cuando se cierra la ventana (weak reference).
    let strips_t   = strips.clone();
    let ma_t       = meter_arcs.clone();
    let state_t    = state.clone();
    let win_weak   = win.downgrade();
    glib::timeout_add_local(std::time::Duration::from_millis(33), move || {
        // Parar si la ventana fue destruida
        if win_weak.upgrade().is_none() { return glib::ControlFlow::Break; }
        // Leer metros globales escritos por el pad probe del pipeline activo (sin lock)
        for b in 0..6 {
            ma_t[b].0.store(processor::display_level(b));
            ma_t[b].1.store(processor::display_gr(b));
        }
        for s in &strips_t { s.da_vu.queue_draw(); s.da_gr.queue_draw(); }
        glib::ControlFlow::Continue
    });

    win.set_child(Some(&root));
    win.present();
}

// ─── Helpers de tandas comerciales próximas ───────────────────────────────────

/// Escribe la marca de "tanda reproducida manualmente" al archivo played_breaks.
fn mark_break_played(hour: u32, minute: u32, home: &std::path::Path) {
    let today = chrono::Local::now().format("%Y%m%d").to_string();
    let key = format!("{} {:02}:{:02}", today, hour, minute);
    let path = home.join(".gradio/data/tmp/played_breaks");
    let mut lines: Vec<String> = std::fs::read_to_string(&path)
        .unwrap_or_default()
        .lines()
        .filter(|l| l.starts_with(&today))
        .map(String::from)
        .collect();
    if !lines.contains(&key) {
        lines.push(key);
    }
    if let Ok(mut f) = std::fs::OpenOptions::new()
        .write(true).create(true).truncate(true).open(&path)
    {
        use std::io::Write;
        for line in &lines {
            let _ = writeln!(f, "{}", line);
        }
    }
}

/// Intercambia dos líneas en un archivo .com de comerciales.
fn swap_com_file_lines(home: &std::path::Path, hour: u32, minute: u32, a: usize, b: usize) {
    let path = home.join(format!(".gradio/data/comerciales/{}/{}.com", hour, minute));
    let Ok(content) = std::fs::read_to_string(&path) else { return };
    let mut lines: Vec<String> = content.lines().map(String::from).collect();
    if a < lines.len() && b < lines.len() {
        lines.swap(a, b);
        let _ = std::fs::write(&path, lines.join("\n") + "\n");
    }
}

/// Elimina una línea de un archivo .com de comerciales.
fn delete_com_file_line(home: &std::path::Path, hour: u32, minute: u32, idx: usize) {
    let path = home.join(format!(".gradio/data/comerciales/{}/{}.com", hour, minute));
    let Ok(content) = std::fs::read_to_string(&path) else { return };
    let mut lines: Vec<String> = content.lines().map(String::from).collect();
    if idx < lines.len() {
        lines.remove(idx);
        let _ = std::fs::write(&path, lines.join("\n") + "\n");
    }
}

/// Verifica si un día (dow 1=Lun..7=Dom) está en la máscara (versión con dow explícito).
fn valid_day_mask_with_dow(dow: u32, mask: &str) -> bool {
    if mask == "0" || mask.is_empty() { return true; }
    mask.chars().any(|c| c.to_digit(10) == Some(dow))
}

/// Verifica si la fecha actual está en el rango start_date..end_date.
fn in_date_range_ui(now: chrono::NaiveDate, start: &str, end: &str) -> bool {
    let start_ok = start == "0" || start.is_empty() ||
        chrono::NaiveDate::parse_from_str(start, "%Y%m%d")
            .map(|d| now >= d).unwrap_or(true);
    let end_ok = end == "0" || end.is_empty() ||
        chrono::NaiveDate::parse_from_str(end, "%Y%m%d")
            .map(|d| now <= d).unwrap_or(true);
    start_ok && end_ok
}

/// Estructura para una tanda próxima.
struct UpcomingBreak {
    hour:        u32,
    minute:      u32,
    // (path, display_name, duration_secs, file_line_idx)
    // file_line_idx: línea real en el .com (puede diferir del índice visual por ítems filtrados)
    items:       Vec<(String, String, f64, usize)>,
    total_dur:   f64,
}

/// Escanea los próximos minutos del horario para encontrar hasta `n` tandas con comerciales.
fn scan_upcoming_breaks(home: &std::path::Path, n: usize) -> Vec<UpcomingBreak> {
    use chrono::{Local, Timelike, Datelike};
    let now = Local::now();
    let today_str = now.format("%Y%m%d").to_string();
    let today_date = now.date_naive();

    // Leer played_breaks de hoy
    let played_path = home.join(".gradio/data/tmp/played_breaks");
    let played: std::collections::HashSet<String> = std::fs::read_to_string(&played_path)
        .unwrap_or_default()
        .lines()
        .filter(|l| l.starts_with(&today_str))
        .map(|l| l.trim().to_string())
        .collect();

    let _ = today_date; // used in loop below via cur_date

    let mut breaks = Vec::new();
    let mut current = now + chrono::Duration::minutes(1);
    let max_minutes = 4 * 60;

    for _ in 0..max_minutes {
        if breaks.len() >= n { break; }
        let h = current.hour();
        let m = current.minute();
        let dow = current.weekday().number_from_monday();
        let cur_date = current.date_naive();

        let com_file = home.join(format!(".gradio/data/comerciales/{}/{}.com", h, m));
        if com_file.exists() {
            let key = format!("{} {:02}:{:02}", today_str, h, m);
            if played.contains(&key) {
                current = current + chrono::Duration::minutes(1);
                continue;
            }

            if let Ok(content) = std::fs::read_to_string(&com_file) {
                let mut items: Vec<(String, String, f64, usize)> = Vec::new();
                for (file_line, line) in content.lines().enumerate() {
                    let line = line.trim();
                    if line.is_empty() { continue; }
                    let parts: Vec<&str> = line.split('|').collect();
                    if parts.len() != 4 { continue; }
                    let path       = parts[0].trim();
                    let days_mask  = parts[1].trim();
                    let start_date = parts[2].trim();
                    let end_date   = parts[3].trim();

                    if !valid_day_mask_with_dow(dow, days_mask) { continue; }
                    if !in_date_range_ui(cur_date, start_date, end_date) { continue; }

                    let display = display_name_for_queue_line(path);
                    let dur = duration_for_queue_line(path);
                    items.push((path.to_string(), display, dur, file_line));
                }

                if !items.is_empty() {
                    let total_dur: f64 = items.iter().map(|(_, _, d, _)| d).sum();
                    breaks.push(UpcomingBreak { hour: h, minute: m, items, total_dur });
                }
            }
        }

        current = current + chrono::Duration::minutes(1);
    }

    breaks
}

/// Construye una tarjeta visual para una tanda de comerciales próxima.
fn build_tanda_card(
    home: &std::path::PathBuf,
    hora: u32,
    minuto: u32,
    items: &[(String, String, f64, usize)],  // (path, name, dur, file_line)
    total_dur: f64,
    color_idx: usize,
    state: SharedState,
    tandas_box: &GtkBox,     // para rebuild inmediato tras ▲▼✕
) -> Frame {
    let card = Frame::new(None::<&str>);
    card.add_css_class("tanda-card");

    let vbox = GtkBox::new(Orientation::Vertical, 2);
    vbox.set_margin_start(6);
    vbox.set_margin_end(6);
    vbox.set_margin_top(4);
    vbox.set_margin_bottom(4);
    // Color de fondo vía clase CSS — background-image no es sobreescrito por el tema
    vbox.add_css_class(&format!("tanda-bg-{}", color_idx % 4));

    // ── Header: hora | duración total | botón play ────────────────────────────
    let header = GtkBox::new(Orientation::Horizontal, 8);
    header.set_margin_bottom(2);

    let lbl_hora = Label::new(Some(&format!("{:02}:{:02}", hora, minuto)));
    lbl_hora.add_css_class("tanda-header");
    lbl_hora.set_xalign(0.0);

    let total_str = secs_to_display(total_dur);
    let lbl_total = Label::new(Some(&total_str));
    lbl_total.set_hexpand(true);
    lbl_total.set_xalign(0.5);

    let btn_play = Button::with_label("\u{25b6}");
    btn_play.set_tooltip_text(Some(&format!("Reproducir tanda {:02}:{:02}", hora, minuto)));

    {
        let home_c = home.clone();
        let items_c: Vec<String> = items.iter().map(|(p, _, _, _)| p.clone()).collect();
        btn_play.connect_clicked(move |_| {
            // 1. Cargar la tanda en la cola activa (reemplaza)
            let comlist = home_c.join(".gradio/data/tmp/comercialeslist4");
            if let Ok(mut f) = std::fs::OpenOptions::new()
                .write(true).create(true).truncate(true).open(&comlist)
            {
                use std::io::Write;
                for path in &items_c {
                    let _ = writeln!(f, "{}", path);
                }
            }
            // 2. Marcar como reproducida para que el scheduler la salte
            mark_break_played(hora, minuto, &home_c);
            // 3. Señal IPC: el timer del player inicia reproducción inmediata
            //    Si hay tema en curso → fadeout y reproduce; si está detenido → reproduce
            let _ = std::fs::write(
                home_c.join(".gradio/data/tmp/cmd_play_tanda"),
                "1",
            );
        });
    }

    header.append(&lbl_hora);
    header.append(&lbl_total);
    header.append(&btn_play);
    vbox.append(&header);
    vbox.append(&gtk4::Separator::new(Orientation::Horizontal));

    // ── Lista de items de la tanda ────────────────────────────────────────────
    // file_lines: índices reales en el .com para swap/delete correcto con ítems filtrados
    let file_lines: Vec<usize> = items.iter().map(|(_, _, _, fl)| *fl).collect();
    let n = items.len();
    for (i, (path, name, dur, _file_line)) in items.iter().enumerate() {
        let row = GtkBox::new(Orientation::Horizontal, 4);

        let lbl_name = Label::new(Some(name));
        lbl_name.set_hexpand(true);
        lbl_name.set_halign(gtk4::Align::Start);
        lbl_name.set_ellipsize(gtk4::pango::EllipsizeMode::End);
        lbl_name.set_tooltip_text(Some(path));

        let dur_str = if *dur > 0.0 { secs_to_display(*dur) } else { "\u{2014}".to_string() };
        let lbl_dur = Label::new(Some(&dur_str));
        lbl_dur.add_css_class("queue-dur-label");

        let btn_up   = Button::with_label("\u{25b2}");
        let btn_down = Button::with_label("\u{25bc}");
        let btn_del  = Button::with_label("\u{2715}");
        btn_up.add_css_class("queue-action-btn");
        btn_down.add_css_class("queue-action-btn");
        btn_del.add_css_class("queue-action-btn");
        btn_up.set_sensitive(i > 0);
        btn_down.set_sensitive(i + 1 < n);

        {
            let h = home.clone(); let tb = tandas_box.clone();
            let hh = home.clone(); let st = state.clone();
            let hr = hora; let mi = minuto;
            let fl_cur  = file_lines[i];
            let fl_prev = if i > 0 { file_lines[i - 1] } else { 0 };
            btn_up.connect_clicked(move |_| {
                swap_com_file_lines(&h, hr, mi, fl_cur, fl_prev);
                rebuild_tandas_box(&tb, &hh, st.clone());
            });
        }
        {
            let h = home.clone(); let tb = tandas_box.clone();
            let hh = home.clone(); let st = state.clone();
            let hr = hora; let mi = minuto;
            let fl_cur  = file_lines[i];
            let fl_next = if i + 1 < n { file_lines[i + 1] } else { 0 };
            btn_down.connect_clicked(move |_| {
                swap_com_file_lines(&h, hr, mi, fl_cur, fl_next);
                rebuild_tandas_box(&tb, &hh, st.clone());
            });
        }
        {
            let h = home.clone(); let tb = tandas_box.clone();
            let hh = home.clone(); let st = state.clone();
            let hr = hora; let mi = minuto;
            let fl = file_lines[i];
            btn_del.connect_clicked(move |_| {
                delete_com_file_line(&h, hr, mi, fl);
                rebuild_tandas_box(&tb, &hh, st.clone());
            });
        }

        row.append(&lbl_name);
        row.append(&lbl_dur);
        row.append(&btn_up);
        row.append(&btn_down);
        row.append(&btn_del);
        vbox.append(&row);
    }

    card.set_child(Some(&vbox));
    card
}

/// Construye el panel de cola activa (comercialeslist4) con el mismo estilo de tarjeta.
fn build_active_queue_card(home: &std::path::PathBuf, tandas_box: &GtkBox, state: SharedState) -> Option<Frame> {
    let path = home.join(".gradio/data/tmp/comercialeslist4");
    let content = std::fs::read_to_string(&path).unwrap_or_default();
    let lines: Vec<String> = content.lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect();
    if lines.is_empty() { return None; }

    let card = Frame::new(None::<&str>);
    card.add_css_class("tanda-card");
    let vbox = GtkBox::new(Orientation::Vertical, 2);
    vbox.set_margin_start(6); vbox.set_margin_end(6);
    vbox.set_margin_top(4);   vbox.set_margin_bottom(4);
    // Fondo oscuro rojizo para distinguir la cola activa de las tandas futuras
    vbox.add_css_class("tanda-bg-active");

    // Header
    let header = GtkBox::new(Orientation::Horizontal, 8);
    header.set_margin_bottom(2);
    let lbl_titulo = Label::new(Some(i18n::tr("cola.titulo")));
    lbl_titulo.add_css_class("tanda-header");
    lbl_titulo.set_xalign(0.0);
    let total_dur: f64 = lines.iter().map(|l| duration_for_queue_line(l)).sum();
    let total_str = secs_to_display(total_dur);
    let lbl_total = Label::new(Some(&total_str));
    lbl_total.set_hexpand(true);
    lbl_total.set_xalign(0.5);
    let btn_vaciar = Button::with_label(i18n::tr("cola.vaciar"));
    btn_vaciar.set_tooltip_text(Some(i18n::tr("tip.empty_queue")));
    {
        let p = path.clone(); let tb = tandas_box.clone();
        let hh = home.clone(); let st = state.clone();
        btn_vaciar.connect_clicked(move |_| {
            let _ = std::fs::write(&p, "");
            rebuild_tandas_box(&tb, &hh, st.clone());
        });
    }
    header.append(&lbl_titulo);
    header.append(&lbl_total);
    header.append(&btn_vaciar);
    vbox.append(&header);
    vbox.append(&gtk4::Separator::new(Orientation::Horizontal));

    // Ítems
    let n = lines.len();
    for (i, line) in lines.iter().enumerate() {
        let row = GtkBox::new(Orientation::Horizontal, 4);
        let name = display_name_for_queue_line(line);
        let lbl_name = Label::new(Some(&name));
        lbl_name.set_hexpand(true);
        lbl_name.set_halign(gtk4::Align::Start);
        lbl_name.set_ellipsize(gtk4::pango::EllipsizeMode::End);
        lbl_name.set_tooltip_text(Some(line));
        let dur = duration_for_queue_line(line);
        let dur_str = if dur > 0.0 { secs_to_display(dur) } else { "—".to_string() };
        let lbl_dur = Label::new(Some(&dur_str));
        lbl_dur.add_css_class("queue-dur-label");
        let btn_del = Button::with_label("\u{2715}");
        btn_del.add_css_class("queue-action-btn");
        {
            let fp = path.clone(); let idx = i; let ntot = n;
            let tb = tandas_box.clone(); let hh = home.clone(); let st = state.clone();
            btn_del.connect_clicked(move |_| {
                if let Ok(c) = std::fs::read_to_string(&fp) {
                    let mut lns: Vec<&str> = c.lines().filter(|l| !l.trim().is_empty()).collect();
                    if idx < lns.len() { lns.remove(idx); }
                    let _ = std::fs::write(&fp, lns.join("\n") + if lns.is_empty() { "" } else { "\n" });
                }
                rebuild_tandas_box(&tb, &hh, st.clone());
                let _ = ntot; // silence unused warning
            });
        }
        row.append(&lbl_name);
        row.append(&lbl_dur);
        row.append(&btn_del);
        vbox.append(&row);
    }

    card.set_child(Some(&vbox));
    Some(card)
}

/// Reconstruye el contenido del box de tandas próximas.
fn rebuild_tandas_box(
    tandas_box: &GtkBox,
    home: &std::path::PathBuf,
    state: SharedState,
) {
    while let Some(child) = tandas_box.first_child() {
        tandas_box.remove(&child);
    }

    // Cola activa (comercialeslist4) arriba, con mismo formato de tarjeta
    if let Some(card) = build_active_queue_card(home, tandas_box, state.clone()) {
        tandas_box.append(&card);
    }

    let breaks = scan_upcoming_breaks(home, 4);
    if breaks.is_empty() {
        let lbl = Label::new(Some(i18n::tr("cola.no_tandas")));
        lbl.set_margin_top(8);
        lbl.set_xalign(0.5);
        tandas_box.append(&lbl);
        return;
    }

    for (idx, br) in breaks.iter().enumerate() {
        let card = build_tanda_card(home, br.hour, br.minute, &br.items, br.total_dur,
                                    idx, state.clone(), tandas_box);
        tandas_box.append(&card);
    }
}

// ─── GStreamer helpers ────────────────────────────────────────────────────────


/// Configuración centralizada: ~/.gradio/data/tmp/gradio.config
/// Línea 1: tarjeta audio principal
/// Línea 2: tarjeta audio CUE
/// Línea 3: nombre de la radio
/// Línea 4: tiempo de fundido (segundos)
#[derive(Clone)]
struct GradioConfig {
    main_dev:        Option<String>,
    cue_dev:         Option<String>,
    station_name:    String,
    crossfade_secs:  f64,
    pisador_enabled: bool,       // true = tocar pisador
    pisador_dir:     String,     // carpeta de pisadores
    pisador_every:   u32,        // cada cuántos temas (1 = todos)
    pisador_exclude:       Vec<String>,// carpetas donde NO se toca pisador
    silence_secs:          f64,        // segundos de silencio para avanzar al siguiente (0 = desact.)
    carpetas_nacionales:   Vec<String>,// carpetas de música nacional (para reportes)
    carpetas_intercultural:Vec<String>,// carpetas de interculturalidad (para reportes)
    // ── gr-client servidor ────────────────────────────────────────────────────
    client_port:  u16,    // puerto TCP del servidor gr-client (default 7777)
    client_token: String, // token de autenticación (vacío = sin auth)
    // ── relay internet ────────────────────────────────────────────────────────
    relay_habilitado: bool,   // true = conectar al servidor relay configurado (GRADIO_RELAY_URL)
    relay_id:         String, // ID de 8 dígitos para el relay
    // ── playlist-refill ───────────────────────────────────────────────────────
    no_repeat_days: u32,      // días hacia atrás para el control de no-repetición (default 3)
    // ── i18n ──────────────────────────────────────────────────────────────────
    locale: Option<String>,   // "es"|"en"|"pt" o None (autodetectar)
    // ── IA (OpenCode) ─────────────────────────────────────────────────────────
    ia_habilitada: bool,      // true = mostrar botón IA y permitir lanzar OpenCode
    // ── Fundido ───────────────────────────────────────────────────────────────
    fundido_exclude: Vec<String>, // carpetas cuyos archivos arrancan/terminan sin crossfade
    // ── Locutor Automático (locución IA, ver Dropbox/LocutorIA) ───────────────
    // Solo una bandera de intención: la generación de audio la hacen agentes
    // externos (claude/opencode) que la leen desde tmp/locutor_auto, no este proceso.
    locutor_habilitado: bool,
    // ── Players (paneles de reproducción visibles) ────────────────────────────
    // 3 = Deck A + Deck B + barra de comercial (actual/default)
    // 2 = un solo deck fusionado + barra de comercial
    // 1 = un solo panel fusionado (deck activo o comercial, lo que suene)
    players: u8,
}

fn config_path() -> PathBuf {
    dirs::home_dir().unwrap().join(".gradio/data/tmp/gradio.config")
}

/// Lee la carpeta de pisadores específica para la hora y día actuales.
/// Devuelve cadena vacía si no hay carpeta configurada para esa hora.
fn pisador_dir_hora_actual() -> String {
    use chrono::Datelike;
    let now  = Local::now();
    let hora = now.hour() as u8;
    let dia  = now.weekday().number_from_monday() as u8;
    let path = dirs::home_dir().unwrap()
        .join(".gradio/data/parrilla")
        .join(dia.to_string())
        .join(format!("{}-{}.pisador", hora, hora + 1));
    fs::read_to_string(&path)
        .map(|s| s.trim().to_string())
        .unwrap_or_default()
}

/// Abre opencode en el terminal del sistema como fallback cuando opencode-terminal
/// no está disponible o no puede ejecutarse (ej. incompatibilidad de glibc).
fn launch_opencode_en_terminal(bin: &PathBuf, dir: &PathBuf) {
    let bin_str = bin.to_string_lossy().into_owned();
    let dir_str = dir.to_string_lossy().into_owned();
    // x-terminal-emulator (gnome-terminal.wrapper) traduce "-e arg1 arg2" → "-- arg1 arg2"
    // No usar "--" directamente: el wrapper lo descarta sin pasarlo a gnome-terminal.
    let _ = std::process::Command::new("x-terminal-emulator")
        .args(["-e", &bin_str, &dir_str])
        .spawn()
        .or_else(|_| std::process::Command::new("gnome-terminal")
            .args(["--", &bin_str, &dir_str])
            .spawn())
        .or_else(|_| std::process::Command::new("xterm")
            .args(["-e", &bin_str, &dir_str])
            .spawn());
}

fn read_gradio_config() -> GradioConfig {
    let path = config_path();
    let text = fs::read_to_string(&path).unwrap_or_default();
    let mut lines = text.lines();
    let main_dev = lines.next().map(|l| l.trim().to_string()).filter(|s| !s.is_empty());
    let cue_dev  = lines.next().map(|l| l.trim().to_string()).filter(|s| !s.is_empty());
    let station_name = lines.next().map(|l| l.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "G Radio".to_string());
    let crossfade_secs = lines.next()
        .and_then(|l| l.trim().parse::<f64>().ok())
        .unwrap_or(3.0);
    // Línea 5: pisador_enabled (1 = sí, 0 = no)
    let pisador_enabled = lines.next()
        .and_then(|l| l.trim().parse::<u32>().ok())
        .map(|v| v != 0)
        .unwrap_or(false);
    // Línea 6: carpeta de pisadores
    let pisador_dir = lines.next()
        .map(|l| l.trim().to_string())
        .unwrap_or_default();
    // Línea 7: cada cuántos temas (0/vacío = mismo que 1)
    let pisador_every = lines.next()
        .and_then(|l| l.trim().parse::<u32>().ok())
        .map(|v| if v == 0 { 1 } else { v })
        .unwrap_or(1);
    // Línea 8: carpetas excluidas separadas por ; entre comillas
    // Formato: "/ruta/uno";"/ruta/dos con ; en nombre"
    let pisador_exclude = lines.next()
        .map(|l| parse_quoted_semicolon_list(l.trim()))
        .unwrap_or_default();
    // Línea 9: segundos de silencio para avanzar (0 = desactivado)
    let silence_secs = lines.next()
        .and_then(|l| l.trim().parse::<f64>().ok())
        .unwrap_or(5.0);
    // Línea 10: carpetas de música nacional
    let carpetas_nacionales = lines.next()
        .map(|l| parse_quoted_semicolon_list(l.trim()))
        .unwrap_or_default();
    // Línea 11: carpetas de interculturalidad
    let carpetas_intercultural = lines.next()
        .map(|l| parse_quoted_semicolon_list(l.trim()))
        .unwrap_or_default();
    // Línea 12: puerto servidor gr-client (default 7777)
    let client_port = lines.next()
        .and_then(|l| l.trim().parse::<u16>().ok())
        .unwrap_or(7777);
    // Línea 13: token de autenticación gr-client (vacío = sin auth)
    let client_token = lines.next()
        .map(|l| l.trim().to_string())
        .unwrap_or_default();
    // Línea 14: relay habilitado (1 = sí, 0 = no)
    let relay_habilitado = lines.next()
        .and_then(|l| l.trim().parse::<u32>().ok())
        .map(|v| v != 0)
        .unwrap_or(false);
    // Línea 15: ID de relay (8 dígitos)
    let relay_id = lines.next()
        .map(|l| l.trim().to_string())
        .unwrap_or_default();
    // Línea 16: días de lookback para no-repetición (default 3, mínimo 1)
    let no_repeat_days = lines.next()
        .and_then(|l| l.trim().parse::<u32>().ok())
        .filter(|&v| v >= 1)
        .unwrap_or(3);
    // Línea 17: locale forzado de la UI ("es"/"en"/"pt" o vacío = autodetectar)
    let locale = lines.next()
        .map(|l| l.trim().to_string())
        .filter(|s| !s.is_empty());
    // Línea 18: IA habilitada (1 = sí, 0 = no)
    let ia_habilitada = lines.next()
        .and_then(|l| l.trim().parse::<u32>().ok())
        .map(|v| v != 0)
        .unwrap_or(false);
    // Línea 19: carpetas que no se funden (crossfade)
    let fundido_exclude = lines.next()
        .map(|l| parse_quoted_semicolon_list(l.trim()))
        .unwrap_or_default();
    // Línea 20: Locutor Automático habilitado (1 = sí, 0 = no)
    let locutor_habilitado = lines.next()
        .and_then(|l| l.trim().parse::<u32>().ok())
        .map(|v| v != 0)
        .unwrap_or(false);
    // Línea 21: Players — cuántos paneles de reproducción se muestran (3/2/1)
    let players = lines.next()
        .and_then(|l| l.trim().parse::<u8>().ok())
        .filter(|v| matches!(v, 1 | 2 | 3))
        .unwrap_or(3);
    GradioConfig { main_dev, cue_dev, station_name, crossfade_secs,
                   pisador_enabled, pisador_dir, pisador_every, pisador_exclude,
                   silence_secs, carpetas_nacionales, carpetas_intercultural,
                   client_port, client_token,
                   relay_habilitado, relay_id, no_repeat_days,
                   locale, ia_habilitada, fundido_exclude, locutor_habilitado,
                   players }
}

/// Parsea una lista de rutas separadas por ; y entre comillas.
/// Ej: "/ruta/a";"/ruta/b con ; en nombre" → vec!["/ruta/a", "/ruta/b con ; en nombre"]
fn parse_quoted_semicolon_list(s: &str) -> Vec<String> {
    let mut result = Vec::new();
    let mut rest = s;
    while !rest.is_empty() {
        rest = rest.trim_start();
        if rest.starts_with('"') {
            rest = &rest[1..];
            if let Some(end) = rest.find('"') {
                result.push(rest[..end].to_string());
                rest = &rest[end + 1..];
                rest = rest.trim_start_matches(';');
            } else {
                // comilla sin cerrar: tomar todo
                result.push(rest.to_string());
                break;
            }
        } else if let Some(end) = rest.find(';') {
            let item = rest[..end].trim().to_string();
            if !item.is_empty() { result.push(item); }
            rest = &rest[end + 1..];
        } else {
            let item = rest.trim().to_string();
            if !item.is_empty() { result.push(item); }
            break;
        }
    }
    result
}

fn fmt_path_list(v: &[String]) -> String {
    v.iter().map(|p| format!("\"{}\"", p)).collect::<Vec<_>>().join(";")
}

fn write_gradio_config(cfg: &GradioConfig) {
    let text = format!(
        "{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
{}
",
        cfg.main_dev.as_deref().unwrap_or(""),
        cfg.cue_dev.as_deref().unwrap_or(""),
        cfg.station_name,
        cfg.crossfade_secs,
        if cfg.pisador_enabled { 1 } else { 0 },
        cfg.pisador_dir,
        cfg.pisador_every,
        fmt_path_list(&cfg.pisador_exclude),
        cfg.silence_secs,
        fmt_path_list(&cfg.carpetas_nacionales),
        fmt_path_list(&cfg.carpetas_intercultural),
        cfg.client_port,
        cfg.client_token,
        if cfg.relay_habilitado { 1 } else { 0 },
        cfg.relay_id,
        cfg.no_repeat_days,
        cfg.locale.as_deref().unwrap_or(""),
        if cfg.ia_habilitada { 1 } else { 0 },
        fmt_path_list(&cfg.fundido_exclude),
        if cfg.locutor_habilitado { 1 } else { 0 },
        cfg.players,
    );
    if let Err(e) = fs::write(config_path(), &text) {
        eprintln!("Error guardando gradio.config: {}", e);
    }
    // Espejo simple en tmp/ para que agentes externos (claude/opencode que
    // generan la locución IA, ver Dropbox/LocutorIA) lean la bandera sin
    // tener que parsear el formato posicional de gradio.config.
    let locutor_flag_path = dirs::home_dir().unwrap().join(".gradio/data/tmp/locutor_auto");
    let _ = fs::write(&locutor_flag_path, if cfg.locutor_habilitado { "1" } else { "0" });
}

// Compatibilidad: funciones auxiliares que usan GradioConfig
fn read_device_config() -> (Option<String>, Option<String>) {
    let cfg = read_gradio_config();
    (cfg.main_dev, cfg.cue_dev)
}


/// Conecta un bus watch al pipeline para leer mensajes `level` del elemento
/// GStreamer y actualizar vu_level_l/r en el AppState.
/// Se llama cada vez que se crea un pipeline de deck A o B.
fn attach_level_watch(pipeline: &gst::Pipeline, state: SharedState) {
    use gst::prelude::ElementExt;
    let bus = match pipeline.bus() {
        Some(b) => b,
        None => return,
    };
    // add_watch corre en el main loop de GLib — seguro para GTK
    // IMPORTANTE: add_watch_local devuelve un BusWatchGuard (RAII). Si se descarta
    // con `let _ = ...`, su Drop llama remove_watch inmediatamente y el watch nunca
    // recibe mensajes. Se usa mem::forget para mantenerlo vivo; el pipeline lo limpia
    // al transicionar a NULL y soltarse del AppState.
    let watch_result = bus.add_watch_local(move |_, msg| {
        if let gst::MessageView::Element(elem) = msg.view() {
            if let Some(structure) = elem.structure() {
                if structure.name() == "level" {
                    // Mapeo dB → display: -60dB=0%, -3dB≈100%
                    let to_display = |db: f64| -> f64 {
                        if db <= -60.0 { return 0.0; }
                        let min_db = -50.0_f64;
                        let max_db =  -3.0_f64;
                        ((db - min_db) / (max_db - min_db)).clamp(0.0, 1.0)
                    };
                    // En GStreamer 1.20+ el campo "rms" es un GValueArray de f64.
                    // Leer directamente por API evita depender del formato del string.
                    let chs: Vec<f64> = if let Ok(arr) = structure.get::<glib::ValueArray>("rms") {
                        arr.iter()
                            .filter_map(|v| v.get::<f64>().ok())
                            .collect()
                    } else {
                        // Fallback: parsear el string serializado (GStreamer < 1.20)
                        let struct_str = structure.to_string();
                        parse_level_field(&struct_str, "rms")
                    };
                    if !chs.is_empty() {
                        if let Ok(mut st) = state.try_lock() {
                            let lev_l = to_display(chs[0]);
                            let lev_r = to_display(*chs.get(1).unwrap_or(&chs[0]));
                            if st.vu_active {
                                st.vu_level_l = lev_l;
                                st.vu_level_r = lev_r;
                            }
                            // Raw en dB (para detector de silencio — umbral real, no display)
                            st.vu_level_l_raw = chs[0];
                            st.vu_level_r_raw = *chs.get(1).unwrap_or(&chs[0]);
                            st.vu_last_level_msg = std::time::Instant::now();
                        }
                    }
                }
            }
        }
        glib::ControlFlow::Continue
    });
    if let Ok(guard) = watch_result {
        std::mem::forget(guard);
    }
}

/// Variante de attach_level_watch para pipelines de comerciales/eventos.
/// Usa enable_sync_message_emission + connect_sync_message en lugar de
/// add_watch_local, de modo que los mensajes EOS/Error siguen disponibles
/// en la cola async del bus para que wait_pipeline_eos los pueda leer con
/// timed_pop. Con add_watch_local (usado por los decks de música) el watch
/// consume los mensajes del bus y wait_pipeline_eos nunca ve el EOS.
fn attach_level_watch_comm(pipeline: &gst::Pipeline, state: SharedState) {
    let bus = match pipeline.bus() {
        Some(b) => b,
        None => return,
    };
    bus.enable_sync_message_emission();
    bus.connect_sync_message(None, move |_bus, msg| {
        if let gst::MessageView::Element(elem) = msg.view() {
            if let Some(structure) = elem.structure() {
                if structure.name() == "level" {
                    let to_display = |db: f64| -> f64 {
                        if db <= -60.0 { return 0.0; }
                        let min_db = -50.0_f64;
                        let max_db =  -3.0_f64;
                        ((db - min_db) / (max_db - min_db)).clamp(0.0, 1.0)
                    };
                    let chs: Vec<f64> = if let Ok(arr) = structure.get::<glib::ValueArray>("rms") {
                        arr.iter()
                            .filter_map(|v| v.get::<f64>().ok())
                            .collect()
                    } else {
                        let struct_str = structure.to_string();
                        parse_level_field(&struct_str, "rms")
                    };
                    if !chs.is_empty() {
                        if let Ok(mut st) = state.try_lock() {
                            let lev_l = to_display(chs[0]);
                            let lev_r = to_display(*chs.get(1).unwrap_or(&chs[0]));
                            if st.vu_active {
                                st.vu_level_l = lev_l;
                                st.vu_level_r = lev_r;
                            }
                            st.vu_level_l_raw = chs[0];
                            st.vu_level_r_raw = *chs.get(1).unwrap_or(&chs[0]);
                            st.vu_last_level_msg = std::time::Instant::now();
                        }
                    }
                }
            }
        }
    });
}

fn parse_level_field(structure_str: &str, field: &str) -> Vec<f64> {
    // Buscar "field=(double)" o "field=(double){"
    let marker = format!("{}=(double)", field);
    let start = match structure_str.find(&marker) {
        Some(p) => p + marker.len(),
        None => return vec![],
    };
    let rest = &structure_str[start..];
    // Extraer hasta el siguiente campo (coma fuera de llaves) o fin de string
    let mut depth = 0usize;
    let mut end = rest.len();
    for (i, c) in rest.char_indices() {
        match c {
            '{' => depth += 1,
            '}' => {
                if depth == 0 { end = i; break; }
                depth -= 1;
                if depth == 0 { end = i + 1; break; }
            }
            ',' if depth == 0 => { end = i; break; }
            _ => {}
        }
    }
    let segment = &rest[..end];
    // Parsear todos los números flotantes (incluye negativos)
    let mut result = Vec::new();
    let mut i = 0usize;
    let bytes = segment.as_bytes();
    while i < bytes.len() {
        // Buscar inicio de número: dígito o '-' seguido de dígito
        if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i+1].is_ascii_digit() {
            let start_n = i;
            i += 1;
            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { i += 1; }
            if let Ok(v) = segment[start_n..i].parse::<f64>() { result.push(v); }
        } else if bytes[i].is_ascii_digit() {
            let start_n = i;
            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { i += 1; }
            if let Ok(v) = segment[start_n..i].parse::<f64>() { result.push(v); }
        } else {
            i += 1;
        }
    }
    result
}

fn build_pipeline(uri: &str, volume: f64, dsp: Option<processor::SharedDsp>) -> Result<gst::Pipeline> {
    let pipeline = gst::Pipeline::new();
    let src = gst::ElementFactory::make("uridecodebin")
        .property("uri", uri)
        .build()?;
    let audioconvert = gst::ElementFactory::make("audioconvert").build()?;
    let audioresample = gst::ElementFactory::make("audioresample").build()?;
    let volume_elem = gst::ElementFactory::make("volume")
        .property("volume", volume)
        .build()?;
    // Sonda de nivel para VU meter — intervalo 100ms, sin peak ni rms falsos
    let level_elem = gst::ElementFactory::make("level")
        .property("interval",  50_000_000u64) // 50ms en nanosegundos
        .property("peak-ttl", 0u64)
        .property("post-messages", true)
        .build()
        .unwrap_or_else(|_| gst::ElementFactory::make("identity").build().unwrap());
    let (main_dev, _) = read_device_config();

    // Cada pipeline recibe su PROPIO DspProcessor con estado limpio.
    // Compartir el mismo Arc causaba chisporoteo al inicio de cada audio: los registros
    // de retardo del crossover (biquad x1/x2/y1/y2) contenían muestras del audio anterior
    // y contaminaban los primeros frames del nuevo hasta que los filtros convergían.
    // La config se lee de disco (JSON pequeño) para evitar contención de mutex con el
    // timer de la ventana de configuración (33 ms) que también bloquea el master Arc.
    let dsp = dsp.or_else(|| {
        let cfg = processor::ProcessorConfig::cargar();
        // Siempre crear probe; process_block() retorna de inmediato si cfg.enabled=false.
        // Así el botón ON/OFF funciona sin reconstruir la pipeline.
        Some(Arc::new(Mutex::new(processor::DspProcessor::new(cfg))))
    });

    if let Some(dsp_arc) = dsp {
        // ── Pipeline con procesador DSP via pad probe (in-place) ─────────────
        let audio_caps = gstreamer_audio::AudioCapsBuilder::new()
            .format(gstreamer_audio::AudioFormat::F32le)
            .rate(48000i32)
            .channels(2i32)
            .build();

        let caps_filter = gst::ElementFactory::make("capsfilter")
            .property("caps", &audio_caps)
            .build()?;

        // Post-DSP: convierte formato Y tasa de muestreo al nativo del sink.
        // wasapi2sink (IAudioClient3) exige exactamente su mix rate; sin audioresample_out
        // el capsfilter forzando 48000 Hz impide que wasapi2 negocie 44100 Hz o su tasa nativa.
        // En Linux son no-ops porque pulsesink acepta F32LE@48k directamente.
        let audioconvert_out  = gst::ElementFactory::make("audioconvert").build()?;
        let audioresample_out = gst::ElementFactory::make("audioresample").build()?;

        let audiosink = match main_dev {
            Some(ref dev) => gst::ElementFactory::make("pulsesink")
                .property("device", dev.as_str())
                .build()
                .unwrap_or_else(|_| gst::ElementFactory::make("autoaudiosink").build().unwrap()),
            None => gst::ElementFactory::make("autoaudiosink").build()?,
        };

        pipeline.add_many([&src, &audioconvert, &audioresample, &volume_elem, &level_elem, &caps_filter, &audioconvert_out, &audioresample_out, &audiosink])?;
        gst::Element::link_many([&audioconvert, &audioresample, &volume_elem, &level_elem, &caps_filter, &audioconvert_out, &audioresample_out, &audiosink])?;

        // Pad probe: intercepta cada buffer, sincroniza config si cambió, aplica DSP in-place
        let src_pad = caps_filter.static_pad("src").unwrap();
        let local_ver = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(
            PROC_CFG_VER.load(std::sync::atomic::Ordering::Relaxed)
        ));
        src_pad.add_probe(gst::PadProbeType::BUFFER, move |_pad, info| {
            // Fast path: DSP apagado — pasar buffer sin tocar ni copiar.
            if !PROC_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
                return gst::PadProbeReturn::Ok;
            }
            if let Some(gst::PadProbeData::Buffer(ref mut buf)) = info.data {
                let buf_mut = buf.make_mut();
                if let Ok(mut map) = buf_mut.map_writable() {
                    let samples: &mut [f32] = unsafe {
                        std::slice::from_raw_parts_mut(
                            map.as_mut_ptr() as *mut f32,
                            map.len() / 4,
                        )
                    };
                    if let Ok(mut dsp) = dsp_arc.try_lock() {
                        // Sincronizar parámetros si el operador movió algún knob
                        let cur = PROC_CFG_VER.load(std::sync::atomic::Ordering::Relaxed);
                        if cur != local_ver.load(std::sync::atomic::Ordering::Relaxed) {
                            if let Ok(g) = proc_dsp_global().try_lock() {
                                if let Some(ref master) = *g {
                                    if let Ok(m) = master.try_lock() {
                                        dsp.update_config(m.cfg.clone());
                                    }
                                }
                            }
                            local_ver.store(cur, std::sync::atomic::Ordering::Relaxed);
                        }
                        // Forzar cfg.enabled=true: PROC_ENABLED ya pasó el gate de arriba.
                        // Evita que un cfg.enabled obsoleto (del momento de construcción del
                        // pipeline) haga que process_block retorne sin procesar.
                        dsp.cfg.enabled = true;
                        dsp.process_block(samples);
                    }
                }
            }
            gst::PadProbeReturn::Ok
        });

        let audioconvert_weak = audioconvert.downgrade();
        src.connect_pad_added(move |_, pad| {
            if let Some(ac) = audioconvert_weak.upgrade() {
                let sink_pad = ac.static_pad("sink").unwrap();
                if !sink_pad.is_linked() { let _ = pad.link(&sink_pad); }
            }
        });

        Ok(pipeline)
    } else {
        // ── Pipeline sin procesador (comportamiento actual) ──────────────────
        let audiosink = match main_dev {
            Some(ref dev) => gst::ElementFactory::make("pulsesink")
                .property("device", dev.as_str())
                .build()
                .unwrap_or_else(|_| gst::ElementFactory::make("autoaudiosink").build().unwrap()),
            None => gst::ElementFactory::make("autoaudiosink").build()?,
        };
        pipeline.add_many([&src, &audioconvert, &audioresample, &volume_elem, &level_elem, &audiosink])?;
        gst::Element::link_many([&audioconvert, &audioresample, &volume_elem, &level_elem, &audiosink])?;

        let audioconvert_weak = audioconvert.downgrade();
        src.connect_pad_added(move |_, pad| {
            if let Some(ac) = audioconvert_weak.upgrade() {
                let sink_pad = ac.static_pad("sink").unwrap();
                if !sink_pad.is_linked() {
                    let _ = pad.link(&sink_pad);
                }
            }
        });

        Ok(pipeline)
    }
}


/// Pipeline para preescucha (CUE) — usa la tarjeta secundaria configurada.
fn build_pipeline_cue(uri: &str) -> Result<gst::Pipeline> {
    let pipeline = gst::Pipeline::new();
    let src = gst::ElementFactory::make("uridecodebin")
        .property("uri", uri)
        .build()?;
    let audioconvert = gst::ElementFactory::make("audioconvert").build()?;
    let audioresample = gst::ElementFactory::make("audioresample").build()?;
    let volume_elem = gst::ElementFactory::make("volume")
        .property("volume", 1.0f64)
        .build()?;
    let (_, cue_dev) = read_device_config();
    let audiosink = match cue_dev {
        Some(ref dev) => {
            gst::ElementFactory::make("pulsesink")
                .property("device", dev.as_str())
                .build()
                .unwrap_or_else(|_| gst::ElementFactory::make("autoaudiosink").build().unwrap())
        }
        None => gst::ElementFactory::make("autoaudiosink").build()?,
    };

    pipeline.add_many([&src, &audioconvert, &audioresample, &volume_elem, &audiosink])?;
    gst::Element::link_many([&audioconvert, &audioresample, &volume_elem, &audiosink])?;

    let audioconvert_weak = audioconvert.downgrade();
    src.connect_pad_added(move |_, pad| {
        if let Some(ac) = audioconvert_weak.upgrade() {
            let sink_pad = ac.static_pad("sink").unwrap();
            if !sink_pad.is_linked() {
                let _ = pad.link(&sink_pad);
            }
        }
    });

    Ok(pipeline)
}




/// Lista de archivos/directorios que se incluyen en el respaldo.
/// Devuelve Vec<(ruta_absoluta, ruta_dentro_del_zip)>
fn backup_file_list(home: &PathBuf) -> Vec<(PathBuf, String)> {
    let gradio = home.join(".gradio/data");
    let mut items: Vec<(PathBuf, String)> = Vec::new();

    // Archivo de configuración principal
    items.push((
        gradio.join("tmp/gradio.config"),
        ".gradio/data/tmp/gradio.config".to_string(),
    ));

    // Configuración de la botonera
    items.push((
        gradio.join("botonera/config.json"),
        ".gradio/data/botonera/config.json".to_string(),
    ));

    // Directorios completos: parrilla, eventos, eventos-espera
    for dir_name in &["parrilla", "eventos", "eventos-espera", "comerciales"] {
        let dir = gradio.join(dir_name);
        if dir.exists() {
            collect_dir_files(&dir, home, &mut items);
        }
    }

    items
}

/// Recorre un directorio recursivamente y agrega cada archivo a la lista.
fn collect_dir_files(dir: &PathBuf, home: &PathBuf, items: &mut Vec<(PathBuf, String)>) {
    let Ok(entries) = fs::read_dir(dir) else { return };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_dir_files(&path, home, items);
        } else {
            // Ruta relativa al home para preservar estructura
            if let Ok(rel) = path.strip_prefix(home) {
                items.push((path.clone(), rel.to_string_lossy().to_string()));
            }
        }
    }
}

/// Crea el zip de respaldo en `dest_path`.
fn create_backup_zip(dest_path: &PathBuf, home: &PathBuf) -> Result<usize> {
    let file = std::fs::File::create(dest_path)?;
    let mut zip = ZipWriter::new(file);
    let options = SimpleFileOptions::default()
        .compression_method(zip::CompressionMethod::Deflated);

    let file_list = backup_file_list(home);
    let mut count = 0usize;

    for (abs_path, zip_path) in &file_list {
        if !abs_path.exists() {
            info!("Respaldo: omitiendo ausente: {:?}", abs_path);
            continue;
        }
        // Crear directorios intermedios en el zip
        let zip_path_norm = zip_path.replace('\\', "/");
        if let Some(parent) = std::path::Path::new(&zip_path_norm).parent() {
            let parent_str = parent.to_string_lossy();
            if !parent_str.is_empty() {
                let _ = zip.add_directory(&format!("{}/", parent_str), options);
            }
        }
        zip.start_file(&zip_path_norm, options)?;
        let data = fs::read(abs_path)?;
        use std::io::Write;
        zip.write_all(&data)?;
        count += 1;
        info!("Respaldo: agregado {}", zip_path_norm);
    }

    zip.finish()?;
    Ok(count)
}

/// Restaura los archivos desde un zip de respaldo.
fn restore_backup_zip(zip_path: &PathBuf, home: &PathBuf) -> Result<usize> {
    let file = std::fs::File::open(zip_path)?;
    let mut archive = zip::ZipArchive::new(file)?;
    let mut count = 0usize;

    for i in 0..archive.len() {
        let mut entry = archive.by_index(i)?;
        let entry_name = entry.name().to_string();
        // Saltar entradas de directorio
        if entry_name.ends_with('/') { continue; }

        let dest = home.join(&entry_name);
        // Crear directorios si no existen
        if let Some(parent) = dest.parent() {
            fs::create_dir_all(parent)?;
        }
        let mut out = std::fs::File::create(&dest)?;
        use std::io::Read;
        let mut buf = Vec::new();
        entry.read_to_end(&mut buf)?;
        use std::io::Write;
        out.write_all(&buf)?;
        count += 1;
        info!("Recuperar: restaurado {:?}", dest);
    }

    Ok(count)
}

/// Muestra un diálogo de información simple.
fn show_info_dialog(parent: &gtk4::Window, title: &str, msg: &str) {
    show_info_dialog_then(parent, title, msg, || {});
}

/// Igual que `show_info_dialog`, pero ejecuta `on_close` cuando el usuario
/// cierra la ventana. Sirve para encadenar acciones (ej. cerrar el diálogo
/// padre solo después de que el aviso fue visto y descartado) sin destruir
/// al padre mientras un modal hijo está vivo (eso colgaba la app).
fn show_info_dialog_then<F: Fn() + 'static>(
    parent: &gtk4::Window,
    title: &str,
    msg: &str,
    on_close: F,
) {
    let d = gtk4::Window::new();
    d.set_title(Some(title));
    d.set_transient_for(Some(parent));
    d.set_modal(true);
    d.set_default_size(400, -1);
    let vbox = GtkBox::new(Orientation::Vertical, 12);
    vbox.set_margin_top(20); vbox.set_margin_bottom(16);
    vbox.set_margin_start(20); vbox.set_margin_end(20);
    let lbl = Label::new(Some(msg));
    lbl.set_wrap(true);
    lbl.set_halign(gtk4::Align::Start);
    lbl.add_css_class("status-label");
    let btn = Button::with_label(i18n::tr("btn.ok"));
    btn.add_css_class("control-btn");
    btn.set_halign(gtk4::Align::End);
    let dc = d.clone();
    btn.connect_clicked(move |_| dc.close());
    vbox.append(&lbl);
    vbox.append(&btn);
    d.set_child(Some(&vbox));
    let cb = std::rc::Rc::new(on_close);
    d.connect_close_request(move |_| {
        cb();
        gtk4::glib::Propagation::Proceed
    });
    d.present();
}

/// Ventana auxiliar para gestionar listas de carpetas.
/// Muestra la lista con botón derecho para borrar y botón para agregar nuevas.
fn show_excluded_dirs_dialog(
    parent: &gtk4::Window,
    model: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
    count_label: Label,
    title: &str,
) {
    let win = gtk4::Window::new();
    win.set_title(Some(title));
    win.set_transient_for(Some(parent));
    win.set_modal(true);
    win.set_default_size(500, 320);

    let vbox = GtkBox::new(Orientation::Vertical, 8);
    vbox.set_margin_top(12);
    vbox.set_margin_bottom(12);
    vbox.set_margin_start(12);
    vbox.set_margin_end(12);

    // Lista
    let listbox = ListBox::new();
    listbox.add_css_class("playlist-list");
    listbox.set_selection_mode(gtk4::SelectionMode::None);

    let scroll = ScrolledWindow::new();
    scroll.set_child(Some(&listbox));
    scroll.set_vexpand(true);
    scroll.set_hexpand(true);

    // Función para reconstruir la lista
    let rebuild_list = {
        let listbox_r = listbox.clone();
        let model_r   = model.clone();
        let count_r   = count_label.clone();
        move || {
            while let Some(ch) = listbox_r.first_child() { listbox_r.remove(&ch); }
            let items = model_r.borrow().clone();
            count_r.set_text(&i18n::tr("cfg.folders_n").replace("{n}", &items.len().to_string()));
            for (i, path) in items.iter().enumerate() {
                let row_box = GtkBox::new(Orientation::Horizontal, 4);
                let lbl = Label::new(Some(path.as_str()));
                lbl.set_halign(gtk4::Align::Start);
                lbl.set_hexpand(true);
                lbl.set_ellipsize(gtk4::pango::EllipsizeMode::Start);
                row_box.append(&lbl);

                let row = ListBoxRow::new();
                row.set_child(Some(&row_box));

                // Botón derecho: menú con Eliminar
                let pop = gtk4::Popover::new();
                pop.set_parent(&row_box);
                pop.set_has_arrow(false);
                pop.set_autohide(true);
                let vp = GtkBox::new(Orientation::Vertical, 0);
                vp.add_css_class("context-menu-box");
                vp.set_margin_top(2); vp.set_margin_bottom(2);
                vp.set_margin_start(2); vp.set_margin_end(2);
                let btn_del = Button::with_label("🗑  Eliminar");
                btn_del.add_css_class("context-menu-item");
                btn_del.add_css_class("context-menu-item-danger");
                vp.append(&btn_del);
                pop.set_child(Some(&vp));

                let model_del  = model_r.clone();
                let count_del  = count_r.clone();
                let lb_del     = listbox_r.clone();
                let pop_del    = pop.clone();
                btn_del.connect_clicked(move |_| {
                    pop_del.popdown();
                    model_del.borrow_mut().remove(i);
                    count_del.set_text(&i18n::tr("cfg.folders_n").replace("{n}", &model_del.borrow().len().to_string()));
                    while let Some(ch) = lb_del.first_child() { lb_del.remove(&ch); }
                    // Re-poblar (llamada indirecta vía señal de modelo)
                    let items2 = model_del.borrow().clone();
                    for path2 in &items2 {
                        let rb2 = GtkBox::new(Orientation::Horizontal, 4);
                        let l2  = Label::new(Some(path2.as_str()));
                        l2.set_halign(gtk4::Align::Start);
                        l2.set_hexpand(true);
                        l2.set_ellipsize(gtk4::pango::EllipsizeMode::Start);
                        rb2.append(&l2);
                        let r2 = ListBoxRow::new();
                        r2.set_child(Some(&rb2));
                        lb_del.append(&r2);
                    }
                    count_del.set_text(&i18n::tr("cfg.folders_n").replace("{n}", &items2.len().to_string()));
                });

                let right = GestureClick::new();
                right.set_button(3);
                let pop_r = pop.clone();
                right.connect_released(move |g, _, x, y| {
                    g.set_state(gtk4::EventSequenceState::Claimed);
                    pop_r.set_pointing_to(Some(&gdk4::Rectangle::new(x as i32, y as i32, 1, 1)));
                    pop_r.popup();
                });
                row_box.add_controller(right);

                listbox_r.append(&row);
            }
        }
    };
    rebuild_list();

    // Botón Agregar carpeta
    let btn_add = Button::with_label("➕ Agregar carpeta…");
    btn_add.add_css_class("control-btn");
    {
        let model_add  = model.clone();
        let win_add    = win.clone();
        let rebuild_c  = rebuild_list.clone();
        btn_add.connect_clicked(move |_| {
            let fc = gtk4::FileChooserDialog::new(
                Some("Agregar carpeta"),
                Some(&win_add),
                gtk4::FileChooserAction::SelectFolder,
                &[("Cancelar", gtk4::ResponseType::Cancel),
                  ("Agregar", gtk4::ResponseType::Accept)],
            );
            fc.set_modal(true);
            let model_c    = model_add.clone();
            let rebuild_c2 = rebuild_c.clone();
            fc.connect_response(move |fc, resp| {
                if resp == gtk4::ResponseType::Accept {
                    if let Some(file) = fc.file() {
                        if let Some(path) = file.path() {
                            let path_str = path.to_string_lossy().to_string();
                            if !model_c.borrow().contains(&path_str) {
                                model_c.borrow_mut().push(path_str);
                            }
                            rebuild_c2();
                        }
                    }
                }
                fc.close();
            });
            fc.present();
        });
    }

    let btn_close = Button::with_label("✔ Cerrar");
    btn_close.add_css_class("control-btn");
    let win_c = win.clone();
    btn_close.connect_clicked(move |_| win_c.close());

    let btn_row = GtkBox::new(Orientation::Horizontal, 8);
    btn_row.set_halign(gtk4::Align::End);
    btn_row.append(&btn_add);
    btn_row.append(&btn_close);

    vbox.append(&scroll);
    vbox.append(&btn_row);
    win.set_child(Some(&vbox));
    win.present();
}

/// Evita el "scroll-steal" clásico de GTK: un GtkComboBoxText/GtkSpinButton
/// sin foco que esté bajo el cursor cambia su valor con la rueda del mouse
/// en vez de dejar que el ScrolledWindow contenedor haga scroll. Reenvía el
/// scroll al ScrolledWindow cuando el widget no tiene el foco.
fn guard_scroll_steal<W: IsA<gtk4::Widget> + Clone>(widget: &W, scroll: &ScrolledWindow) {
    let ctrl = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
    ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
    let w: gtk4::Widget = widget.clone().upcast();
    let scroll = scroll.clone();
    ctrl.connect_scroll(move |_, _dx, dy| {
        if w.has_focus() {
            return glib::Propagation::Proceed;
        }
        let vadj = scroll.vadjustment();
        let max = (vadj.upper() - vadj.page_size()).max(vadj.lower());
        vadj.set_value((vadj.value() + dy * vadj.step_increment()).clamp(vadj.lower(), max));
        glib::Propagation::Stop
    });
    widget.add_controller(ctrl);
}

/// Muestra el diálogo de Configuración GR
fn show_config_dialog(parent: &gtk4::Window) {
    let dialog = gtk4::Window::new();
    dialog.set_title(Some(i18n::tr("win.config")));
    dialog.set_transient_for(Some(parent));
    dialog.set_modal(true);
    dialog.set_default_size(640, 740);
    dialog.set_resizable(true);

    let cfg = read_gradio_config();
    let devices = get_pulse_devices();
    let home_vol = home_dir();
    let upvol_cur   = read_f64_from_file(&home_vol.join(".gradio/data/tmp/upvol")).clamp(0.0, 100.0);
    let downvol_cur = read_f64_from_file(&home_vol.join(".gradio/data/tmp/downvol")).clamp(0.0, 100.0);

    let grid = gtk4::Grid::new();
    grid.set_margin_top(16);
    grid.set_margin_bottom(8);
    grid.set_margin_start(16);
    grid.set_margin_end(16);
    grid.set_row_spacing(10);
    grid.set_column_spacing(12);

    // ── Fila 0: Tarjeta principal ──
    let lbl_main = gtk4::Label::new(Some(i18n::tr("cfg.audio_main")));
    lbl_main.set_halign(gtk4::Align::End);
    lbl_main.add_css_class("config-label");
    let combo_main = gtk4::DropDown::from_strings(
        &devices.iter().map(|s| s.as_str()).collect::<Vec<_>>()
    );
    combo_main.set_hexpand(true);
    if let Some(ref cur) = cfg.main_dev {
        if let Some(pos) = devices.iter().position(|d| d == cur) {
            combo_main.set_selected(pos as u32);
        }
    }
    grid.attach(&lbl_main,   0, 0, 1, 1);
    grid.attach(&combo_main, 1, 0, 1, 1);

    // ── Fila 1: Tarjeta CUE ──
    let lbl_cue = gtk4::Label::new(Some(i18n::tr("cfg.audio_cue")));
    lbl_cue.set_halign(gtk4::Align::End);
    lbl_cue.add_css_class("config-label");
    let combo_cue = gtk4::DropDown::from_strings(
        &devices.iter().map(|s| s.as_str()).collect::<Vec<_>>()
    );
    combo_cue.set_hexpand(true);
    if let Some(ref cur) = cfg.cue_dev {
        if let Some(pos) = devices.iter().position(|d| d == cur) {
            combo_cue.set_selected(pos as u32);
        }
    }
    grid.attach(&lbl_cue,   0, 1, 1, 1);
    grid.attach(&combo_cue, 1, 1, 1, 1);

    // ── Fila 2: Nombre de la radio ──
    let lbl_name = gtk4::Label::new(Some(i18n::tr("cfg.medio_name")));
    lbl_name.set_halign(gtk4::Align::End);
    lbl_name.add_css_class("config-label");
    let entry_name = gtk4::Entry::new();
    entry_name.set_text(&cfg.station_name);
    entry_name.set_hexpand(true);
    grid.attach(&lbl_name,   0, 2, 1, 1);
    grid.attach(&entry_name, 1, 2, 1, 1);

    // ── Fila 3: Tiempo de fundido ──
    let lbl_mix = gtk4::Label::new(Some(i18n::tr("cfg.crossfade")));
    lbl_mix.set_halign(gtk4::Align::End);
    lbl_mix.add_css_class("config-label");
    let spin_mix = gtk4::SpinButton::with_range(0.0, 30.0, 0.5);
    spin_mix.set_value(cfg.crossfade_secs);
    spin_mix.set_digits(1);
    grid.attach(&lbl_mix,  0, 3, 1, 1);
    grid.attach(&spin_mix, 1, 3, 1, 1);

    // ── Fila 4: Pisador sobre temas ──
    let lbl_pis_en = gtk4::Label::new(Some(i18n::tr("cfg.pisador_on")));
    lbl_pis_en.set_halign(gtk4::Align::End);
    lbl_pis_en.add_css_class("config-label");
    let switch_pis = gtk4::Switch::new();
    switch_pis.set_active(cfg.pisador_enabled);
    switch_pis.set_halign(gtk4::Align::Start);
    grid.attach(&lbl_pis_en,  0, 4, 1, 1);
    grid.attach(&switch_pis,  1, 4, 1, 1);

    // ── Fila 5: Carpeta de pisadores ──
    let lbl_pis_dir = gtk4::Label::new(Some(i18n::tr("cfg.pisador_dir")));
    lbl_pis_dir.set_halign(gtk4::Align::End);
    lbl_pis_dir.add_css_class("config-label");
    let entry_pis_dir = gtk4::Entry::new();
    entry_pis_dir.set_text(&cfg.pisador_dir);
    entry_pis_dir.set_hexpand(true);
    let btn_pis_browse = Button::with_label("📂");
    btn_pis_browse.set_tooltip_text(Some(i18n::tr("btn.choose_folder")));
    let pisdir_row = GtkBox::new(Orientation::Horizontal, 4);
    pisdir_row.set_hexpand(true);
    pisdir_row.append(&entry_pis_dir);
    pisdir_row.append(&btn_pis_browse);
    grid.attach(&lbl_pis_dir,  0, 5, 1, 1);
    grid.attach(&pisdir_row,   1, 5, 1, 1);

    // Señal del botón de carpeta pisadores
    {
        let entry_ref = entry_pis_dir.clone();
        let win_ref   = dialog.clone();
        btn_pis_browse.connect_clicked(move |_| {
            let fc = gtk4::FileChooserDialog::new(
                Some(i18n::tr("cfg.pick_pisador")),
                Some(&win_ref),
                gtk4::FileChooserAction::SelectFolder,
                &[(i18n::tr("btn.cancel"), gtk4::ResponseType::Cancel),
                  (i18n::tr("btn.select"), gtk4::ResponseType::Accept)],
            );
            fc.set_modal(true);
            if !entry_ref.text().is_empty() {
                let _ = fc.set_current_folder(
                    Some(&gio::File::for_path(entry_ref.text().as_str()))
                );
            }
            let entry_c = entry_ref.clone();
            fc.connect_response(move |fc, resp| {
                if resp == gtk4::ResponseType::Accept {
                    if let Some(file) = fc.file() {
                        if let Some(path) = file.path() {
                            entry_c.set_text(&path.to_string_lossy());
                        }
                    }
                }
                fc.close();
            });
            fc.present();
        });
    }

    // ── Fila 6: Cada cuántos temas ──
    let lbl_pis_ev = gtk4::Label::new(Some(i18n::tr("cfg.pisador_every")));
    lbl_pis_ev.set_halign(gtk4::Align::End);
    lbl_pis_ev.add_css_class("config-label");
    let spin_pis_ev = gtk4::SpinButton::with_range(1.0, 99.0, 1.0);
    spin_pis_ev.set_value(cfg.pisador_every as f64);
    spin_pis_ev.set_digits(0);
    grid.attach(&lbl_pis_ev,   0, 6, 1, 1);
    grid.attach(&spin_pis_ev,  1, 6, 1, 1);

    // ── Fila 7: Carpetas excluidas — botón que abre ventana auxiliar ──
    let lbl_excl = gtk4::Label::new(Some(i18n::tr("cfg.pisador_excl")));
    lbl_excl.set_halign(gtk4::Align::End);
    lbl_excl.add_css_class("config-label");
    // Modelo compartido de exclusiones (Rc<RefCell> para pasar entre closures GTK)
    let excl_model: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
        std::rc::Rc::new(std::cell::RefCell::new(cfg.pisador_exclude.clone()));
    let btn_excl_edit = Button::with_label(i18n::tr("btn.edit_list"));
    btn_excl_edit.add_css_class("control-btn");
    btn_excl_edit.set_hexpand(false);
    // Etiqueta que muestra cuántas hay
    let lbl_excl_count = Label::new(Some(
        &i18n::tr("cfg.folders_n").replace("{n}", &cfg.pisador_exclude.len().to_string())
    ));
    lbl_excl_count.add_css_class("time-label");
    let excl_row = GtkBox::new(Orientation::Horizontal, 8);
    excl_row.append(&btn_excl_edit);
    excl_row.append(&lbl_excl_count);
    grid.attach(&lbl_excl,    0, 7, 1, 1);
    grid.attach(&excl_row,    1, 7, 1, 1);

    // Señal: abrir ventana auxiliar de lista de excluidas
    {
        let model_ref   = excl_model.clone();
        let count_ref   = lbl_excl_count.clone();
        let win_ref     = dialog.clone();
        btn_excl_edit.connect_clicked(move |_| {
            show_excluded_dirs_dialog(
                &win_ref,
                model_ref.clone(),
                count_ref.clone(),
                "Carpetas que no se pisan",
            );
        });
    }

    // ── Fila 8: Carpetas Nacionales ──
    let lbl_nac = gtk4::Label::new(Some(i18n::tr("cfg.nacionales")));
    lbl_nac.set_halign(gtk4::Align::End);
    lbl_nac.add_css_class("config-label");
    let nac_model: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
        std::rc::Rc::new(std::cell::RefCell::new(cfg.carpetas_nacionales.clone()));
    let btn_nac_edit = Button::with_label(i18n::tr("btn.edit_list"));
    btn_nac_edit.add_css_class("control-btn");
    btn_nac_edit.set_hexpand(false);
    let lbl_nac_count = Label::new(Some(
        &i18n::tr("cfg.folders_n").replace("{n}", &cfg.carpetas_nacionales.len().to_string())
    ));
    lbl_nac_count.add_css_class("time-label");
    let nac_row = GtkBox::new(Orientation::Horizontal, 8);
    nac_row.append(&btn_nac_edit);
    nac_row.append(&lbl_nac_count);
    grid.attach(&lbl_nac,  0, 8, 1, 1);
    grid.attach(&nac_row,  1, 8, 1, 1);
    {
        let model_ref = nac_model.clone();
        let count_ref = lbl_nac_count.clone();
        let win_ref   = dialog.clone();
        btn_nac_edit.connect_clicked(move |_| {
            show_excluded_dirs_dialog(
                &win_ref, model_ref.clone(), count_ref.clone(),
                "Carpetas Nacionales",
            );
        });
    }

    // ── Fila 9: Carpetas Intercultural ──
    let lbl_int = gtk4::Label::new(Some(i18n::tr("cfg.intercultural")));
    lbl_int.set_halign(gtk4::Align::End);
    lbl_int.add_css_class("config-label");
    let int_model: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
        std::rc::Rc::new(std::cell::RefCell::new(cfg.carpetas_intercultural.clone()));
    let btn_int_edit = Button::with_label(i18n::tr("btn.edit_list"));
    btn_int_edit.add_css_class("control-btn");
    btn_int_edit.set_hexpand(false);
    let lbl_int_count = Label::new(Some(
        &i18n::tr("cfg.folders_n").replace("{n}", &cfg.carpetas_intercultural.len().to_string())
    ));
    lbl_int_count.add_css_class("time-label");
    let int_row = GtkBox::new(Orientation::Horizontal, 8);
    int_row.append(&btn_int_edit);
    int_row.append(&lbl_int_count);
    grid.attach(&lbl_int,  0, 9, 1, 1);
    grid.attach(&int_row,  1, 9, 1, 1);
    {
        let model_ref = int_model.clone();
        let count_ref = lbl_int_count.clone();
        let win_ref   = dialog.clone();
        btn_int_edit.connect_clicked(move |_| {
            show_excluded_dirs_dialog(
                &win_ref, model_ref.clone(), count_ref.clone(),
                "Carpetas Intercultural",
            );
        });
    }

    // ── Fila 10: Detector de silencio ──
    let lbl_silence = gtk4::Label::new(Some(i18n::tr("cfg.silence")));
    lbl_silence.set_halign(gtk4::Align::End);
    lbl_silence.add_css_class("config-label");
    let spin_silence = gtk4::SpinButton::with_range(0.0, 60.0, 1.0);
    spin_silence.set_value(cfg.silence_secs);
    spin_silence.set_digits(0);
    spin_silence.set_tooltip_text(Some(i18n::tr("tip.silence")));
    grid.attach(&lbl_silence,  0, 10, 1, 1);
    grid.attach(&spin_silence, 1, 10, 1, 1);

    // ── Fila 11: Volumen principal ──
    let lbl_upvol = gtk4::Label::new(Some(i18n::tr("cfg.upvol")));
    lbl_upvol.set_halign(gtk4::Align::End);
    lbl_upvol.add_css_class("config-label");
    let spin_upvol = gtk4::SpinButton::with_range(0.0, 100.0, 1.0);
    spin_upvol.set_value(upvol_cur);
    spin_upvol.set_digits(0);
    spin_upvol.set_tooltip_text(Some(i18n::tr("tip.upvol")));
    grid.attach(&lbl_upvol,  0, 11, 1, 1);
    grid.attach(&spin_upvol, 1, 11, 1, 1);

    // ── Fila 12: Volumen duck ──
    let lbl_downvol = gtk4::Label::new(Some(i18n::tr("cfg.downvol")));
    lbl_downvol.set_halign(gtk4::Align::End);
    lbl_downvol.add_css_class("config-label");
    let spin_downvol = gtk4::SpinButton::with_range(0.0, 100.0, 1.0);
    spin_downvol.set_value(downvol_cur);
    spin_downvol.set_digits(0);
    spin_downvol.set_tooltip_text(Some(i18n::tr("tip.downvol")));
    grid.attach(&lbl_downvol,  0, 12, 1, 1);
    grid.attach(&spin_downvol, 1, 12, 1, 1);

    // ── Fila 13: Puerto del servidor gr-client ──
    let lbl_port = gtk4::Label::new(Some(i18n::tr("cfg.client_port")));
    lbl_port.set_halign(gtk4::Align::End);
    lbl_port.add_css_class("config-label");
    let spin_port = gtk4::SpinButton::with_range(1.0, 65535.0, 1.0);
    spin_port.set_value(cfg.client_port as f64);
    spin_port.set_digits(0);
    spin_port.set_tooltip_text(Some(i18n::tr("tip.client_port")));
    grid.attach(&lbl_port,  0, 13, 1, 1);
    grid.attach(&spin_port, 1, 13, 1, 1);

    // ── Fila 14: Token de acceso gr-client ──
    let lbl_tok = gtk4::Label::new(Some(i18n::tr("cfg.client_token")));
    lbl_tok.set_halign(gtk4::Align::End);
    lbl_tok.add_css_class("config-label");
    let entry_tok = gtk4::Entry::new();
    entry_tok.set_text(&cfg.client_token);
    entry_tok.set_hexpand(true);
    entry_tok.set_visibility(false);
    entry_tok.set_input_purpose(gtk4::InputPurpose::Password);
    entry_tok.set_placeholder_text(Some(i18n::tr("cfg.client_token_ph")));
    entry_tok.set_tooltip_text(Some(i18n::tr("tip.client_token")));
    grid.attach(&lbl_tok,   0, 14, 1, 1);
    grid.attach(&entry_tok, 1, 14, 1, 1);

    // ── Fila 15: Relay internet habilitado ──
    let lbl_relay = gtk4::Label::new(Some(i18n::tr("cfg.relay")));
    lbl_relay.set_halign(gtk4::Align::End);
    lbl_relay.add_css_class("config-label");
    let switch_relay = gtk4::Switch::new();
    switch_relay.set_active(cfg.relay_habilitado);
    switch_relay.set_halign(gtk4::Align::Start);
    switch_relay.set_tooltip_text(Some(i18n::tr("tip.relay")));
    grid.attach(&lbl_relay,    0, 15, 1, 1);
    grid.attach(&switch_relay, 1, 15, 1, 1);

    // ── Fila 16: ID de relay ──
    let lbl_rid = gtk4::Label::new(Some(i18n::tr("cfg.relay_id")));
    lbl_rid.set_halign(gtk4::Align::End);
    lbl_rid.add_css_class("config-label");
    let relay_id_box = GtkBox::new(Orientation::Horizontal, 6);
    let entry_rid = gtk4::Entry::new();
    entry_rid.set_text(&cfg.relay_id);
    entry_rid.set_hexpand(true);
    entry_rid.set_max_length(8);
    entry_rid.set_placeholder_text(Some("12345678"));
    entry_rid.set_tooltip_text(Some(i18n::tr("tip.relay_id")));
    let btn_gen_id = gtk4::Button::with_label(i18n::tr("btn.generate"));
    btn_gen_id.set_tooltip_text(Some(i18n::tr("tip.gen_id")));
    {
        let entry_rid2 = entry_rid.clone();
        btn_gen_id.connect_clicked(move |_| {
            use std::time::{SystemTime, UNIX_EPOCH};
            // ID pseudoaleatorio de 8 dígitos
            let seed = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map(|d| d.subsec_nanos())
                .unwrap_or(12345678);
            // Mezcla simple para distribuir los dígitos
            let id = format!("{:08}", (seed as u64 * 6364136223846793005 + 1) % 100_000_000);
            entry_rid2.set_text(&id);
        });
    }
    relay_id_box.append(&entry_rid);
    relay_id_box.append(&btn_gen_id);
    grid.attach(&lbl_rid,      0, 16, 1, 1);
    grid.attach(&relay_id_box, 1, 16, 1, 1);

    // ── Fila 17: Días de no-repetición ──
    let lbl_days = gtk4::Label::new(Some(i18n::tr("cfg.no_repeat")));
    lbl_days.set_halign(gtk4::Align::End);
    lbl_days.add_css_class("config-label");
    let spin_days = gtk4::SpinButton::with_range(1.0, 30.0, 1.0);
    spin_days.set_value(cfg.no_repeat_days as f64);
    spin_days.set_digits(0);
    spin_days.set_tooltip_text(Some(i18n::tr("tip.no_repeat")));
    grid.attach(&lbl_days,  0, 17, 1, 1);
    grid.attach(&spin_days, 1, 17, 1, 1);

    // ── Fila 18: Idioma de la UI ──
    let lbl_locale = gtk4::Label::new(Some(i18n::tr("lang.label")));
    lbl_locale.set_halign(gtk4::Align::End);
    lbl_locale.add_css_class("config-label");
    let combo_locale = gtk4::ComboBoxText::new();
    combo_locale.append(Some("auto"), i18n::tr("lang.auto"));
    combo_locale.append(Some("es"),   "Español");
    combo_locale.append(Some("en"),   "English");
    combo_locale.append(Some("pt"),   "Português");
    let cur_loc = cfg.locale.clone().unwrap_or_else(|| "auto".to_string());
    if !combo_locale.set_active_id(Some(&cur_loc)) { combo_locale.set_active_id(Some("auto")); }
    grid.attach(&lbl_locale,   0, 18, 1, 1);
    grid.attach(&combo_locale, 1, 18, 1, 1);

    // ── Fila 19: Inteligencia Artificial ──
    let lbl_ia = gtk4::Label::new(Some(i18n::tr("cfg.ia")));
    lbl_ia.set_halign(gtk4::Align::End);
    lbl_ia.add_css_class("config-label");
    let switch_ia = gtk4::Switch::new();
    switch_ia.set_active(cfg.ia_habilitada);
    switch_ia.set_halign(gtk4::Align::Start);
    switch_ia.set_tooltip_text(Some(i18n::tr("tip.ia")));
    grid.attach(&lbl_ia,    0, 19, 1, 1);
    grid.attach(&switch_ia, 1, 19, 1, 1);

    // Advertencia al habilitar IA: mostrar diálogo de aceptación explícita
    {
        let sw_ia = switch_ia.clone();
        let dlg_parent = dialog.clone();
        // Flag de uso único para evitar re-entrada al hacer set_active desde el diálogo
        let ia_accepted = std::rc::Rc::new(std::cell::Cell::new(cfg.ia_habilitada));
        let ia_accepted_c = ia_accepted.clone();
        switch_ia.connect_state_set(move |sw, new_state| {
            if new_state && !ia_accepted_c.get() {
                // Bloquear: mostrar diálogo de advertencia
                let warn = gtk4::Window::new();
                warn.set_title(Some(i18n::tr("dlg.ia_warn_title")));
                warn.set_transient_for(Some(&dlg_parent));
                warn.set_modal(true);
                warn.set_default_size(460, -1);
                let vbox = GtkBox::new(Orientation::Vertical, 14);
                vbox.set_margin_top(20); vbox.set_margin_bottom(16);
                vbox.set_margin_start(20); vbox.set_margin_end(20);
                let lbl_w = gtk4::Label::new(Some(i18n::tr("dlg.ia_warn_body")));
                lbl_w.set_wrap(true);
                lbl_w.set_halign(gtk4::Align::Start);
                lbl_w.add_css_class("status-label");
                let btn_row = GtkBox::new(Orientation::Horizontal, 8);
                btn_row.set_halign(gtk4::Align::End);
                let btn_no  = Button::with_label(i18n::tr("btn.cancel"));
                let btn_yes = Button::with_label(i18n::tr("dlg.ia_accept"));
                btn_no.add_css_class("control-btn");
                btn_yes.add_css_class("control-btn");
                let sw_c  = sw_ia.clone();
                let ia_c  = ia_accepted_c.clone();
                let wc    = warn.clone();
                btn_yes.connect_clicked(move |_| {
                    ia_c.set(true);
                    sw_c.set_active(true);
                    wc.close();
                });
                let wc2 = warn.clone();
                btn_no.connect_clicked(move |_| { wc2.close(); });
                btn_row.append(&btn_no);
                btn_row.append(&btn_yes);
                vbox.append(&lbl_w);
                vbox.append(&btn_row);
                warn.set_child(Some(&vbox));
                warn.present();
                return glib::Propagation::Stop; // bloquear cambio de estado hasta aceptación
            }
            if !new_state {
                ia_accepted_c.set(false);
            }
            glib::Propagation::Proceed // permitir
        });
        // Silenciar la advertencia en toggles programáticos (set_active desde btn_yes)
        let _ = sw_ia; // mantener referencia viva
    }

    // ── Fila 20: Locutor Automático (locución IA, ver Dropbox/LocutorIA) ──
    // Solo una bandera de intención por ahora: no lanza nada este proceso.
    // Agentes externos (claude/opencode) leen tmp/locutor_auto para decidir
    // si generan locuciones y las insertan como evento/comercial.
    let lbl_locutor = gtk4::Label::new(Some(i18n::tr("cfg.locutor")));
    lbl_locutor.set_halign(gtk4::Align::End);
    lbl_locutor.add_css_class("config-label");
    let switch_locutor = gtk4::Switch::new();
    switch_locutor.set_active(cfg.locutor_habilitado);
    switch_locutor.set_halign(gtk4::Align::Start);
    switch_locutor.set_tooltip_text(Some(i18n::tr("tip.locutor")));
    grid.attach(&lbl_locutor,    0, 20, 1, 1);
    grid.attach(&switch_locutor, 1, 20, 1, 1);

    // Advertencia al habilitar Locutor Automático: mismo patrón que IA —
    // deja claro que la locución la genera un agente externo (Claude/OpenCode),
    // no este proceso, antes de aceptar explícitamente.
    {
        let sw_locutor = switch_locutor.clone();
        let dlg_parent = dialog.clone();
        let locutor_accepted = std::rc::Rc::new(std::cell::Cell::new(cfg.locutor_habilitado));
        let locutor_accepted_c = locutor_accepted.clone();
        switch_locutor.connect_state_set(move |sw, new_state| {
            if new_state && !locutor_accepted_c.get() {
                let warn = gtk4::Window::new();
                warn.set_title(Some(i18n::tr("dlg.locutor_warn_title")));
                warn.set_transient_for(Some(&dlg_parent));
                warn.set_modal(true);
                warn.set_default_size(460, -1);
                let vbox = GtkBox::new(Orientation::Vertical, 14);
                vbox.set_margin_top(20); vbox.set_margin_bottom(16);
                vbox.set_margin_start(20); vbox.set_margin_end(20);
                let lbl_w = gtk4::Label::new(Some(i18n::tr("dlg.locutor_warn_body")));
                lbl_w.set_wrap(true);
                lbl_w.set_halign(gtk4::Align::Start);
                lbl_w.add_css_class("status-label");
                let btn_row = GtkBox::new(Orientation::Horizontal, 8);
                btn_row.set_halign(gtk4::Align::End);
                let btn_no  = Button::with_label(i18n::tr("btn.cancel"));
                let btn_yes = Button::with_label(i18n::tr("dlg.locutor_accept"));
                btn_no.add_css_class("control-btn");
                btn_yes.add_css_class("control-btn");
                let sw_c  = sw_locutor.clone();
                let loc_c = locutor_accepted_c.clone();
                let wc    = warn.clone();
                btn_yes.connect_clicked(move |_| {
                    loc_c.set(true);
                    sw_c.set_active(true);
                    wc.close();
                });
                let wc2 = warn.clone();
                btn_no.connect_clicked(move |_| { wc2.close(); });
                btn_row.append(&btn_no);
                btn_row.append(&btn_yes);
                vbox.append(&lbl_w);
                vbox.append(&btn_row);
                warn.set_child(Some(&vbox));
                warn.present();
                return glib::Propagation::Stop;
            }
            if !new_state {
                locutor_accepted_c.set(false);
            }
            glib::Propagation::Proceed
        });
        let _ = sw_locutor;
    }

    // ── Fila 21: Carpetas que no se funden ──
    let lbl_fundido = gtk4::Label::new(Some(i18n::tr("cfg.fundido_excl")));
    lbl_fundido.set_halign(gtk4::Align::End);
    lbl_fundido.add_css_class("config-label");
    let fundido_model: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
        std::rc::Rc::new(std::cell::RefCell::new(cfg.fundido_exclude.clone()));
    let btn_fundido_edit = Button::with_label(i18n::tr("btn.edit_list"));
    btn_fundido_edit.add_css_class("control-btn");
    btn_fundido_edit.set_hexpand(false);
    btn_fundido_edit.set_tooltip_text(Some(i18n::tr("tip.fundido_excl")));
    let lbl_fundido_count = Label::new(Some(
        &i18n::tr("cfg.folders_n").replace("{n}", &cfg.fundido_exclude.len().to_string())
    ));
    lbl_fundido_count.add_css_class("time-label");
    let fundido_row = GtkBox::new(Orientation::Horizontal, 8);
    fundido_row.append(&btn_fundido_edit);
    fundido_row.append(&lbl_fundido_count);
    grid.attach(&lbl_fundido,  0, 21, 1, 1);
    grid.attach(&fundido_row,  1, 21, 1, 1);
    {
        let model_ref = fundido_model.clone();
        let count_ref = lbl_fundido_count.clone();
        let win_ref   = dialog.clone();
        btn_fundido_edit.connect_clicked(move |_| {
            show_excluded_dirs_dialog(
                &win_ref, model_ref.clone(), count_ref.clone(),
                "Carpetas que no se funden",
            );
        });
    }

    // ── Fila 22: Skin (apariencia) ──
    let lbl_skin = gtk4::Label::new(Some(i18n::tr("skin.label")));
    lbl_skin.set_halign(gtk4::Align::End);
    lbl_skin.add_css_class("config-label");
    let combo_skin = gtk4::ComboBoxText::new();
    combo_skin.append(Some(""), i18n::tr("skin.default"));
    for nombre in skin::listar() {
        combo_skin.append(Some(&nombre), &nombre);
    }
    let cur_skin = skin::nombre_activo().unwrap_or_default();
    if !combo_skin.set_active_id(Some(&cur_skin)) { combo_skin.set_active_id(Some("")); }
    grid.attach(&lbl_skin,   0, 22, 1, 1);
    grid.attach(&combo_skin, 1, 22, 1, 1);

    // ── Fila 23: Players (paneles de reproducción visibles) ──
    let lbl_players = gtk4::Label::new(Some(i18n::tr("players.label")));
    lbl_players.set_halign(gtk4::Align::End);
    lbl_players.add_css_class("config-label");
    let combo_players = gtk4::ComboBoxText::new();
    combo_players.append(Some("3"), i18n::tr("players.3"));
    combo_players.append(Some("2"), i18n::tr("players.2"));
    combo_players.append(Some("1"), i18n::tr("players.1"));
    combo_players.set_active_id(Some(&cfg.players.to_string()));
    grid.attach(&lbl_players,   0, 23, 1, 1);
    grid.attach(&combo_players, 1, 23, 1, 1);

    // ── Botones ──
    let btn_box = GtkBox::new(Orientation::Horizontal, 8);
    btn_box.set_margin_top(4);
    btn_box.set_margin_bottom(16);
    btn_box.set_margin_start(16);
    btn_box.set_margin_end(16);

    // Izquierda: Respaldar / Recuperar
    let btn_backup  = Button::with_label("📦 Respaldar GR");
    let btn_restore = Button::with_label("📂 Recuperar GR");
    btn_backup.add_css_class("control-btn");
    btn_restore.add_css_class("control-btn");

    let left_box = GtkBox::new(Orientation::Horizontal, 8);
    left_box.set_hexpand(true);
    left_box.append(&btn_backup);
    left_box.append(&btn_restore);

    // Derecha: Cancelar / Guardar
    let btn_save   = Button::with_label(&format!("💾 {}", i18n::tr("btn.save")));
    let btn_cancel = Button::with_label(i18n::tr("btn.cancel"));
    btn_save.add_css_class("control-btn");
    btn_cancel.add_css_class("control-btn");
    let right_box = GtkBox::new(Orientation::Horizontal, 8);
    right_box.set_halign(gtk4::Align::End);
    right_box.append(&btn_cancel);
    right_box.append(&btn_save);

    btn_box.append(&left_box);
    btn_box.append(&right_box);

    let scroll = ScrolledWindow::new();
    scroll.set_child(Some(&grid));
    scroll.set_vexpand(true);
    scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
    scroll.set_vscrollbar_policy(gtk4::PolicyType::Automatic);

    // El diálogo tiene muchos combos/spinners dentro de este ScrolledWindow;
    // sin esto, pasar la rueda del mouse sobre cualquiera de ellos para
    // llegar a un campo más abajo cambia su valor en vez de hacer scroll
    // (fue lo que le pasó al idioma: "cambio de idioma" al guardar sin
    // haber tocado el combo, solo por pasar el scroll sobre él).
    guard_scroll_steal(&combo_locale, &scroll);
    guard_scroll_steal(&combo_skin,   &scroll);
    guard_scroll_steal(&combo_players, &scroll);
    guard_scroll_steal(&spin_mix,      &scroll);
    guard_scroll_steal(&spin_pis_ev,   &scroll);
    guard_scroll_steal(&spin_silence,  &scroll);
    guard_scroll_steal(&spin_upvol,    &scroll);
    guard_scroll_steal(&spin_downvol,  &scroll);
    guard_scroll_steal(&spin_port,     &scroll);
    guard_scroll_steal(&spin_days,     &scroll);

    let vbox = GtkBox::new(Orientation::Vertical, 0);
    vbox.append(&scroll);
    vbox.append(&btn_box);
    dialog.set_child(Some(&vbox));

    let dlg_cancel = dialog.clone();
    btn_cancel.connect_clicked(move |_| dlg_cancel.close());

    // ── Respaldar GR ──────────────────────────────────────────────────────────
    {
        let win_bk   = dialog.clone();
        let home_bk  = dirs::home_dir().unwrap();
        btn_backup.connect_clicked(move |_| {
            let fc = gtk4::FileChooserDialog::new(
                Some("Guardar respaldo GR"),
                Some(&win_bk),
                gtk4::FileChooserAction::Save,
                &[("Cancelar", gtk4::ResponseType::Cancel),
                  ("Guardar",  gtk4::ResponseType::Accept)],
            );
            fc.set_modal(true);
            // Nombre sugerido con fecha
            let fecha = chrono::Local::now().format("%Y%m%d-%H%M").to_string();
            fc.set_current_name(&format!("gradio-backup-{}.zip", fecha));
            // Filtro zip
            let filter = gtk4::FileFilter::new();
            filter.set_name(Some(i18n::tr("dlg.filter_zip")));
            filter.add_pattern("*.zip");
            fc.add_filter(&filter);

            let home_c = home_bk.clone();
            let win_c  = win_bk.clone();
            fc.connect_response(move |fc, resp| {
                if resp == gtk4::ResponseType::Accept {
                    if let Some(file) = fc.file() {
                        if let Some(mut dest) = file.path() {
                            // Asegurar extensión .zip
                            if dest.extension().and_then(|e| e.to_str()) != Some("zip") {
                                dest.set_extension("zip");
                            }
                            match create_backup_zip(&dest, &home_c) {
                                Ok(n) => {
                                    info!("Respaldo creado: {:?} ({} archivos)", dest, n);
                                    show_info_dialog(
                                        &win_c,
                                        i18n::tr("dlg.backup_title"),
                                        &i18n::tr("dlg.backup_body")
                                            .replace("{n}",    &n.to_string())
                                            .replace("{path}", &dest.display().to_string()),
                                    );
                                }
                                Err(e) => {
                                    error!("Error creando respaldo: {}", e);
                                    show_info_dialog(&win_c, i18n::tr("dlg.error"),
                                        &i18n::tr("dlg.backup_err").replace("{err}", &e.to_string()));
                                }
                            }
                        }
                    }
                }
                fc.close();
            });
            fc.present();
        });
    }

    // ── Recuperar GR ─────────────────────────────────────────────────────────
    {
        let win_rs  = dialog.clone();
        let home_rs = dirs::home_dir().unwrap();
        btn_restore.connect_clicked(move |_| {
            let fc = gtk4::FileChooserDialog::new(
                Some(i18n::tr("dlg.sel_backup")),
                Some(&win_rs),
                gtk4::FileChooserAction::Open,
                &[(i18n::tr("btn.cancel"),  gtk4::ResponseType::Cancel),
                  (i18n::tr("dlg.btn_restore"), gtk4::ResponseType::Accept)],
            );
            fc.set_modal(true);
            let filter = gtk4::FileFilter::new();
            filter.set_name(Some(i18n::tr("dlg.filter_zip")));
            filter.add_pattern("*.zip");
            fc.add_filter(&filter);

            let home_c = home_rs.clone();
            let win_c  = win_rs.clone();
            fc.connect_response(move |fc, resp| {
                if resp == gtk4::ResponseType::Accept {
                    if let Some(file) = fc.file() {
                        if let Some(zip_path) = file.path() {
                            // Confirmar antes de sobreescribir
                            match restore_backup_zip(&zip_path, &home_c) {
                                Ok(n) => {
                                    info!("Recuperación completada: {} archivos", n);
                                    show_info_dialog(
                                        &win_c,
                                        i18n::tr("dlg.recovery_title"),
                                        &i18n::tr("dlg.recovery_body").replace("{n}", &n.to_string()),
                                    );
                                }
                                Err(e) => {
                                    error!("Error en recuperación: {}", e);
                                    show_info_dialog(&win_c, i18n::tr("dlg.error"),
                                        &i18n::tr("dlg.recovery_err").replace("{err}", &e.to_string()));
                                }
                            }
                        }
                    }
                }
                fc.close();
            });
            fc.present();
        });
    }

    let dlg_save  = dialog.clone();
    let combo_m2   = combo_main.clone();
    let combo_c2   = combo_cue.clone();
    let entry_n2   = entry_name.clone();
    let spin_m2    = spin_mix.clone();
    let switch_p2  = switch_pis.clone();
    let entry_pd2  = entry_pis_dir.clone();
    let spin_pe2   = spin_pis_ev.clone();
    let excl_m2    = excl_model.clone();
    let spin_si2   = spin_silence.clone();
    let spin_uv2   = spin_upvol.clone();
    let spin_dv2   = spin_downvol.clone();
    let nac_m2     = nac_model.clone();
    let int_m2     = int_model.clone();
    let devs2      = devices.clone();
    let spin_port2    = spin_port.clone();
    let entry_tok2    = entry_tok.clone();
    let switch_relay2 = switch_relay.clone();
    let entry_rid2    = entry_rid.clone();
    let spin_days2    = spin_days.clone();
    let combo_locale2 = combo_locale.clone();
    let combo_skin2   = combo_skin.clone();
    let combo_players2 = combo_players.clone();
    let switch_ia2    = switch_ia.clone();
    let switch_locutor2 = switch_locutor.clone();
    let fundido_m2    = fundido_model.clone();
    btn_save.connect_clicked(move |_| {
        let idx_m = combo_m2.selected() as usize;
        let idx_c = combo_c2.selected() as usize;
        let main_dev = devs2.get(idx_m).cloned()
            .filter(|s| s != "(auto)").map(|s| if s.is_empty() { None } else { Some(s) })
            .flatten();
        let cue_dev = devs2.get(idx_c).cloned()
            .filter(|s| s != "(auto)").map(|s| if s.is_empty() { None } else { Some(s) })
            .flatten();
        // Tomar locale del combo del diálogo
        let prev_locale = read_gradio_config().locale;
        let sel_locale_id = combo_locale2.active_id().map(|s| s.to_string()).unwrap_or_else(|| "auto".into());
        let new_locale = if sel_locale_id == "auto" { None } else { Some(sel_locale_id) };
        let locale_changed = prev_locale != new_locale;
        let prev_skin = skin::nombre_activo().unwrap_or_default();
        let new_skin = combo_skin2.active_id().map(|s| s.to_string()).unwrap_or_default();
        let skin_changed = prev_skin != new_skin;
        let prev_players = read_gradio_config().players;
        let new_players: u8 = combo_players2.active_id()
            .and_then(|s| s.parse().ok())
            .unwrap_or(3);
        let players_changed = prev_players != new_players;
        let new_cfg = GradioConfig {
            main_dev,
            cue_dev,
            station_name:          entry_n2.text().to_string(),
            crossfade_secs:        spin_m2.value(),
            pisador_enabled:       switch_p2.is_active(),
            pisador_dir:           entry_pd2.text().to_string(),
            pisador_every:         spin_pe2.value() as u32,
            pisador_exclude:       excl_m2.borrow().clone(),
            silence_secs:          spin_si2.value(),
            carpetas_nacionales:   nac_m2.borrow().clone(),
            carpetas_intercultural:int_m2.borrow().clone(),
            client_port:           spin_port2.value() as u16,
            client_token:          entry_tok2.text().to_string(),
            relay_habilitado:      switch_relay2.is_active(),
            relay_id:              entry_rid2.text().to_string(),
            no_repeat_days:        spin_days2.value() as u32,
            locale:                new_locale,
            ia_habilitada:         switch_ia2.is_active(),
            fundido_exclude:       fundido_m2.borrow().clone(),
            locutor_habilitado:    switch_locutor2.is_active(),
            players:               new_players,
        };
        // Validar: relay habilitado requiere token
        if new_cfg.relay_habilitado && new_cfg.client_token.trim().is_empty() {
            show_info_dialog(
                &dlg_save,
                i18n::tr("dlg.token_title"),
                i18n::tr("dlg.token_body"),
            );
            entry_tok2.add_css_class("error");
            entry_tok2.grab_focus();
            return;
        }
        entry_tok2.remove_css_class("error");

        write_gradio_config(&new_cfg);
        let home_sv = home_dir();
        let _ = fs::write(home_sv.join(".gradio/data/tmp/upvol"),   format!("{}", spin_uv2.value() as u32));
        let _ = fs::write(home_sv.join(".gradio/data/tmp/downvol"), format!("{}", spin_dv2.value() as u32));
        let _ = fs::write(home_sv.join(".gradio/data/tmp/skin"), &new_skin);
        info!("Configuración guardada en gradio.config");
        if locale_changed {
            let dlg_to_close = dlg_save.clone();
            show_info_dialog_then(
                &dlg_save,
                i18n::tr("lang.restart_title"),
                i18n::tr("lang.restart_body"),
                move || dlg_to_close.close(),
            );
        } else if skin_changed {
            let dlg_to_close = dlg_save.clone();
            show_info_dialog_then(
                &dlg_save,
                i18n::tr("skin.restart_title"),
                i18n::tr("skin.restart_body"),
                move || dlg_to_close.close(),
            );
        } else if players_changed {
            let dlg_to_close = dlg_save.clone();
            show_info_dialog_then(
                &dlg_save,
                i18n::tr("players.restart_title"),
                i18n::tr("players.restart_body"),
                move || dlg_to_close.close(),
            );
        } else {
            dlg_save.close();
        }
    });

    dialog.present();
}

/// Devuelve lista de nombres de dispositivos PulseAudio/ALSA disponibles.
/// Usa `pactl list short sinks` si está disponible, sino devuelve lista básica.
fn get_pulse_devices() -> Vec<String> {
    let mut devices = vec!["(auto)".to_string()];
    if let Ok(out) = std::process::Command::new("pactl")
        .args(["list", "short", "sinks"])
        .output()
    {
        for line in String::from_utf8_lossy(&out.stdout).lines() {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() >= 2 {
                devices.push(parts[1].to_string());
            }
        }
    }
    devices
}

fn path_to_uri(path: &Path) -> String {
    let s = path.to_string_lossy();
    if s.starts_with("http://") || s.starts_with("https://") {
        return s.into_owned();
    }
    #[cfg(target_os = "windows")]
    {
        // Convertir backslashes y percent-encodear todo excepto chars seguros.
        // Espacios, tildes y caracteres no-ASCII deben codificarse byte a byte.
        let fwd = s.replace('\\', "/");
        let encoded: String = fwd.bytes().map(|b| match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
            | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => (b as char).to_string(),
            _ => format!("%{:02X}", b),
        }).collect();
        if encoded.len() >= 2 && encoded.as_bytes()[1] == b':' {
            return format!("file:///{}", encoded);
        }
        return format!("file://{}", encoded);
    }
    #[cfg(not(target_os = "windows"))]
    {
        let encoded: String = s.bytes().map(|b| match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
            | b'-' | b'_' | b'.' | b'~' | b'/' => (b as char).to_string(),
            _ => format!("%{:02X}", b),
        }).collect();
        format!("file://{}", encoded)
    }
}

fn pipeline_position(pipeline: &gst::Pipeline) -> f64 {
    pipeline
        .query_position::<gst::ClockTime>()
        .map(|t| t.seconds() as f64)
        .unwrap_or(0.0)
}

fn pipeline_duration(pipeline: &gst::Pipeline) -> f64 {
    pipeline
        .query_duration::<gst::ClockTime>()
        .map(|t| t.seconds() as f64)
        .unwrap_or(0.0)
}

fn set_pipeline_volume(pipeline: &gst::Pipeline, vol: f64) {
    for element in pipeline.iterate_elements().into_iter().flatten() {
        if element.factory().map(|f| f.name() == "volume").unwrap_or(false) {
            element.set_property("volume", vol.clamp(0.0, 1.0));
        }
    }
}

/// Libera un pipeline de GStreamer de forma segura.
/// Se ejecuta en un hilo separado para no bloquear el main loop de GTK
/// (pipeline.state() con timeout bloquearía la UI y GStreamer no podría
/// procesar su propia transición a NULL → deadlock).
fn shutdown_pipeline(pipeline: gst::Pipeline) {
    // NO llamar set_bus(None) ni set_flushing antes de la transición:
    // quitar el bus mientras el pipeline está en PLAYING impide que los
    // elementos puedan completar el handshake de estado, haciendo que la
    // transición a NULL falle y el pipeline sea descartado aún en PLAYING.
    std::thread::spawn(move || {
        // GStreamer maneja la cadena PLAYING→PAUSED→READY→NULL internamente.
        let _ = pipeline.set_state(gst::State::Null);
        // 30 segundos: suficiente para archivos locales (~inmediato) y streams lentos.
        // Si expira, el pipeline cae con CRITICAL warning; con 5s ocurría con frecuencia.
        let _ = pipeline.state(gst::ClockTime::from_seconds(30));
        // Silenciar el bus watch una vez que ya estamos en NULL, para
        // evitar callbacks tardíos después del drop.
        if let Some(bus) = pipeline.bus() {
            bus.set_flushing(true);
        }
        // drop implicito: pipeline ya en NULL, sin warning
    });
}

fn pipeline_seek(pipeline: &gst::Pipeline, secs: f64) {
    let _ = pipeline.seek_simple(
        gst::SeekFlags::FLUSH | gst::SeekFlags::KEY_UNIT,
        gst::ClockTime::from_seconds(secs as u64),
    );
}

// ─── GUI ──────────────────────────────────────────────────────────────────────


/// Crea un botón cuadrado con imagen PNG embebida.
/// `size` es el lado en píxeles del botón.
fn icon_button(png_bytes: &[u8], tooltip: &str, size: i32) -> Button {
    let btn = Button::new();
    btn.set_tooltip_text(Some(tooltip));
    btn.set_size_request(size, size);
    btn.set_hexpand(false);
    btn.set_vexpand(false);

    let loader = gdk4::gdk_pixbuf::PixbufLoader::new();
    loader.write(png_bytes).unwrap_or(());
    loader.close().unwrap_or(());
    if let Some(pixbuf) = loader.pixbuf() {
        // Escalar al tamaño deseado (icono = size - 8px de margen)
        let icon_size = (size - 10).max(16);
        if let Some(scaled) = pixbuf.scale_simple(
            icon_size, icon_size,
            gdk4::gdk_pixbuf::InterpType::Bilinear,
        ) {
            let texture = gdk4::Texture::for_pixbuf(&scaled);
            let image = gtk4::Image::from_paintable(Some(&texture));
            btn.set_child(Some(&image));
        }
    }
    btn
}

/// Igual que icon_button pero para ToggleButton.
fn icon_toggle_button(png_bytes: &[u8], tooltip: &str, size: i32) -> ToggleButton {
    let btn = ToggleButton::new();
    btn.set_tooltip_text(Some(tooltip));
    btn.set_size_request(size, size);
    btn.set_hexpand(false);
    btn.set_vexpand(false);

    let loader = gdk4::gdk_pixbuf::PixbufLoader::new();
    loader.write(png_bytes).unwrap_or(());
    loader.close().unwrap_or(());
    if let Some(pixbuf) = loader.pixbuf() {
        let icon_size = (size - 10).max(16);
        if let Some(scaled) = pixbuf.scale_simple(
            icon_size, icon_size,
            gdk4::gdk_pixbuf::InterpType::Bilinear,
        ) {
            let texture = gdk4::Texture::for_pixbuf(&scaled);
            let image = gtk4::Image::from_paintable(Some(&texture));
            btn.set_child(Some(&image));
        }
    }
    btn
}

/// Actualiza el icono de un botón existente.
fn set_button_icon(btn: &Button, png_bytes: &[u8], size: i32) {
    let loader = gdk4::gdk_pixbuf::PixbufLoader::new();
    loader.write(png_bytes).unwrap_or(());
    loader.close().unwrap_or(());
    if let Some(pixbuf) = loader.pixbuf() {
        let icon_size = (size - 10).max(16);
        if let Some(scaled) = pixbuf.scale_simple(
            icon_size, icon_size,
            gdk4::gdk_pixbuf::InterpType::Bilinear,
        ) {
            let texture = gdk4::Texture::for_pixbuf(&scaled);
            let image = gtk4::Image::from_paintable(Some(&texture));
            btn.set_child(Some(&image));
        }
    }
}

// ── Iconos embebidos (con override de skin) ────────────────────────────────
// Cada función devuelve el icono del skin activo si lo reemplaza, o el PNG
// embebido por defecto. El nombre pasado a `skin::icono` es el archivo que
// un skin debe colocar en `~/.gradio/data/skins/<nombre>/iconos/`.
macro_rules! icono_skin {
    ($fn_name:ident, $archivo:literal, $ruta:literal) => {
        #[allow(dead_code)]
        fn $fn_name() -> Vec<u8> {
            skin::icono($archivo, include_bytes!($ruta))
        }
    };
}

icono_skin!(icon_proc_off, "processor-off.png", "../assets/processor-off.png");
icono_skin!(icon_proc_on, "processor-on.png", "../assets/processor-on.png");
icono_skin!(icon_proc_cfg, "processor-config.png", "../assets/processor-config.png");

icono_skin!(icon_queue_up, "up.png", "../assets/up.png");
icono_skin!(icon_queue_down, "down.png", "../assets/down.png");
icono_skin!(icon_queue_trash, "trash.png", "../assets/trash.png");
icono_skin!(icon_play_off, "play-off.png", "../assets/play-off.png");
icono_skin!(icon_play_on, "play-on.png", "../assets/play-on.png");
icono_skin!(icon_pausa, "pausa.png", "../assets/pausa.png");
icono_skin!(icon_stop, "stop.png", "../assets/stop.png");
icono_skin!(icon_repetir, "repetir.png", "../assets/repetir.png");
icono_skin!(icon_siguiente, "siguiente.png", "../assets/siguiente.png");
icono_skin!(icon_al_final, "detener-siguiente.png", "../assets/detener-siguiente.png");
icono_skin!(icon_loop_off, "24-Lopp-off.png", "../assets/24-Lopp-off.png");
icono_skin!(icon_loop_on, "24-Lopp-on.png", "../assets/24-Lopp-on.png");
icono_skin!(icon_hora_off, "hora-off.png", "../assets/hora-off.png");
icono_skin!(icon_hora_on, "hora-on.png", "../assets/hora-on.png");
icono_skin!(icon_fadeout_off, "fadeout-off.png", "../assets/fadeout-off.png");
icono_skin!(icon_fadeout_on, "fadeout-on.png", "../assets/fadeout-on.png");
icono_skin!(icon_rec_off, "rec-off.png", "../assets/rec-off.png");
icono_skin!(icon_rec_on, "rec-on.png", "../assets/rec-on.png");
icono_skin!(icon_pisador, "sello.png", "../assets/sello.png");
icono_skin!(icon_robot, "robot.png", "../assets/robot.png");
icono_skin!(icon_vaciar, "vaciar.png", "../assets/vaciar.png");
icono_skin!(icon_busqueda, "busqueda.png", "../assets/busqueda.png");
icono_skin!(icon_gr_on, "GR-On.png", "../assets/GR-On.png");
icono_skin!(icon_gr_off, "GR-Off.png", "../assets/GR-Off.png");
icono_skin!(icon_playlist, "playlist.png", "../assets/playlist.png");
icono_skin!(icon_gradio, "gradio.png", "../assets/gradio.png");
icono_skin!(icon_pautaje, "pautaje48x48.png", "../assets/pautaje48x48.png");
icono_skin!(icon_parrilla, "parrilla48x48.png", "../assets/parrilla48x48.png");
icono_skin!(icon_playlist48, "playlist48x48.png", "../assets/playlist48x48.png");
icono_skin!(icon_botonera, "botonera48x48.png", "../assets/botonera48x48.png");
icono_skin!(icon_visor, "visor48x48.png", "../assets/visor48x48.png");
icono_skin!(icon_grabar, "grabar48x48.png", "../assets/grabar48x48.png");
icono_skin!(icon_config, "config48x48.png", "../assets/config48x48.png");
icono_skin!(icon_reportes, "reportes.png", "../assets/reportes.png");

/// Interrumpe el pautaje en curso: apaga pipeline_comm, activa abort_pautaje.
/// La función async del pautaje detecta el flag y no llama start_next_track.
fn interrupt_pautaje(state: &SharedState) {
    let mut st = state.lock().unwrap();
    st.abort_pautaje = true;
    st.stream_skip = true;
    if let Some(p) = st.pipeline_comm.take() {
        // Flush del bus para desbloquear wait_pipeline_eos inmediatamente
        if let Some(bus) = p.bus() { bus.set_flushing(true); }
        shutdown_pipeline(p);
    }
    st.commercials_playing = false;
    st.eventos_playing = false;
}

/// Muestra un diálogo de confirmación cuando el usuario arrastra un audio al deck
/// mientras se reproducen comerciales o eventos.  Si confirma, llama on_confirm.
fn show_confirm_interrupt_dialog(
    parent_widget: &impl gtk4::prelude::IsA<gtk4::Widget>,
    on_confirm: impl Fn() + 'static,
) {
    let dialog = gtk4::Window::new();
    dialog.set_title(Some("Comerciales en curso"));
    dialog.set_modal(true);
    dialog.set_default_size(380, -1);
    if let Some(root) = parent_widget.root() {
        if let Ok(win) = root.downcast::<gtk4::Window>() {
            dialog.set_transient_for(Some(&win));
        }
    }
    let vbox = GtkBox::new(Orientation::Vertical, 12);
    vbox.set_margin_top(20); vbox.set_margin_bottom(16);
    vbox.set_margin_start(20); vbox.set_margin_end(20);
    let lbl = Label::new(Some(i18n::tr("dlg.confirm_interrumpir")));
    lbl.set_wrap(true);
    vbox.append(&lbl);
    let btn_box = GtkBox::new(Orientation::Horizontal, 8);
    btn_box.set_halign(gtk4::Align::End);
    let btn_cancel    = Button::with_label(i18n::tr("btn.cancel"));
    let btn_interrupt = Button::with_label(i18n::tr("btn.interrupt"));
    btn_interrupt.add_css_class("control-btn");
    btn_box.append(&btn_cancel);
    btn_box.append(&btn_interrupt);
    vbox.append(&btn_box);
    dialog.set_child(Some(&vbox));

    let d = dialog.clone();
    btn_cancel.connect_clicked(move |_| { d.close(); });
    let d = dialog.clone();
    btn_interrupt.connect_clicked(move |_| {
        on_confirm();
        d.close();
    });
    dialog.present();
}

/// `deck`: `Some(ActiveDeck::A/B)` fija el panel a ese deck (modo Players=3,
/// comportamiento de siempre). `None` = panel dinámico: cada acción
/// (play/pausa/stop/loop/seek) resuelve `st.active_deck` en el momento de
/// ejecutarse, para que el mismo panel "siga" al deck que tenga el control
/// (usado en Players=2/1).
fn build_deck_frame(
    label: &str,
    state: SharedState,
    deck: Option<ActiveDeck>,
    title_label: &Label,
    time_label: &Label,
    total_label: &Label,
    seek_scale: &Scale,
) -> (Frame, Button) {
    let frame = Frame::new(Some(label));
    frame.add_css_class("deck-frame");

    let vbox = GtkBox::new(Orientation::Vertical, 6);
    vbox.set_margin_top(8);
    vbox.set_margin_bottom(8);
    vbox.set_margin_start(8);
    vbox.set_margin_end(8);

    // ── Título del tema ──
    title_label.set_halign(gtk4::Align::Start);
    title_label.add_css_class("track-title");
    title_label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
    vbox.append(title_label);

    // ── Barra de tiempo ──
    let time_box = GtkBox::new(Orientation::Horizontal, 6);
    time_label.add_css_class("time-label");
    total_label.add_css_class("time-label");
    time_box.append(time_label);
    time_box.append(seek_scale);
    time_box.append(total_label);
    seek_scale.set_hexpand(true);
    vbox.append(&time_box);

    // ── Botones de control ──
    let btn_box = GtkBox::new(Orientation::Horizontal, 4);
    btn_box.set_halign(gtk4::Align::Center);

    let btn_play      = icon_button(&icon_play_off(),  "Play",           44);
    let btn_pause     = icon_button(&icon_pausa(),     "Pausa",          44);
    let btn_repeat    = icon_button(&icon_repetir(),   "Repetir",        44);
    let btn_next_track= icon_button(&icon_siguiente(), "Siguiente tema", 44);
    let btn_stop_end  = icon_button(&icon_al_final(),  "Detener al final",44);
    let btn_stop      = icon_button(&icon_stop(),      "Stop",           44);
    let btn_loop      = icon_button(&icon_loop_off(),  "Loop",           44);

    for btn in [&btn_play, &btn_pause, &btn_repeat, &btn_next_track, &btn_stop_end, &btn_stop, &btn_loop] {
        btn.add_css_class("deck-icon-btn");
        btn_box.append(btn);
    }
    vbox.append(&btn_box);
    frame.set_child(Some(&vbox));

    // ── Señales ──
    let s = state.clone();
    btn_play.connect_clicked(move |_| {
        let mut st = s.lock().unwrap();
        let d = deck.unwrap_or(st.active_deck);
        action_play(&mut st, d);
    });

    let s = state.clone();
    btn_pause.connect_clicked(move |_| {
        let mut st = s.lock().unwrap();
        let d = deck.unwrap_or(st.active_deck);
        action_pause(&mut st, d);
    });

    let s = state.clone();
    btn_repeat.connect_clicked(move |_| {
        let mut st = s.lock().unwrap();
        let d = deck.unwrap_or(st.active_deck);
        action_repeat(&mut st, d);
    });

    // Siguiente tema con crossfade
    let s = state.clone();
    btn_next_track.connect_clicked(move |_| {
        let next_idx = {
            let st = s.lock().unwrap();
            if st.playlist.is_empty() { return; }
            st.current_index % st.playlist.len()
        };
        start_next_track(s.clone(), next_idx);
    });

    let s = state.clone();
    btn_stop_end.connect_clicked(move |_| {
        let mut st = s.lock().unwrap();
        st.stop_after_track = !st.stop_after_track;
        info!("Stop al final del tema: {}", if st.stop_after_track { "activado" } else { "cancelado" });
    });

    let s = state.clone();
    btn_stop.connect_clicked(move |_| {
        let mut st = s.lock().unwrap();
        let d = deck.unwrap_or(st.active_deck);
        action_stop(&mut st, d);
    });

    // Botón Loop: alterna loop_deck_a/b y cambia color del botón
    let s = state.clone();
    btn_loop.connect_clicked(move |btn| {
        let mut st = s.lock().unwrap();
        let d = deck.unwrap_or(st.active_deck);
        let active = match d {
            ActiveDeck::A => { st.loop_deck_a = !st.loop_deck_a; st.loop_deck_a }
            ActiveDeck::B => { st.loop_deck_b = !st.loop_deck_b; st.loop_deck_b }
        };
        if active {
            btn.remove_css_class("deck-icon-btn");
            btn.add_css_class("deck-icon-btn-active");
            set_button_icon(btn, &icon_loop_on(), 44);
            info!("Deck {:?}: loop activado", d);
        } else {
            btn.remove_css_class("deck-icon-btn-active");
            btn.add_css_class("deck-icon-btn");
            set_button_icon(btn, &icon_loop_off(), 44);
            info!("Deck {:?}: loop desactivado", d);
        }
    });

    // Seek manual: connect_change_value se dispara SOLO por acción del usuario
    let s = state.clone();
    seek_scale.connect_change_value(move |_scale, _scroll, value| {
        let st = s.lock().unwrap();
        let d = deck.unwrap_or(st.active_deck);
        let pipeline = match d {
            ActiveDeck::A => st.pipeline_a.as_ref(),
            ActiveDeck::B => st.pipeline_b.as_ref(),
        };
        if let Some(p) = pipeline {
            pipeline_seek(p, value);
        }
        glib::Propagation::Proceed
    });

    // ── DropTarget único: STRING para arrastre interno + URIs de file managers ──
    // "playlist-row:N"  → reproducir ese tema en ESTE deck con crossfade
    // URIs de archivos  → insertar al frente y reproducir en ESTE deck
    {
        let s = state.clone();
        let frame_ref = frame.clone();

        // Solo STRING - maneja tanto arrastre interno como URIs de Nemo
        let drop_target = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY);
        {
            drop_target.connect_drop(move |_target, value, _x, _y| {
                let (pautaje_activo, target_deck) = {
                    let st = s.lock().unwrap();
                    let pautaje = st.commercials_playing || st.eventos_playing;
                    let deck = match st.active_deck { ActiveDeck::A => ActiveDeck::B, ActiveDeck::B => ActiveDeck::A };
                    (pautaje, deck)
                };

                let text = match value.get::<String>() {
                    Ok(t) => t,
                    Err(_) => return false,
                };
                let text = text.trim().to_string();

                // Determinar idx (e insertar en playlist si es URI externa)
                let idx = if text.starts_with("playlist-row:") {
                    match text["playlist-row:".len()..].parse::<usize>() {
                        Ok(n) => n,
                        Err(_) => return false,
                    }
                } else {
                    let uri = text.lines().next().unwrap_or("").trim().trim_start_matches("file://");
                    if uri.is_empty() { return false; }
                    let path = PathBuf::from(uri);
                    if !path.exists() { return false; }
                    let track = make_track_from_path(path);
                    let playlist_path = home_dir().join(".gradio/data/tmp/playlist4");
                    let mut st = s.lock().unwrap();
                    st.playlist.insert(0, track);
                    save_playlist_to_file(&st.playlist, &playlist_path);
                    st.playlist_version = st.playlist_version.wrapping_add(1);
                    0
                };

                if pautaje_activo {
                    let s2 = s.clone();
                    show_confirm_interrupt_dialog(&frame_ref, move || {
                        interrupt_pautaje(&s2);
                        play_track_to_deck(s2.clone(), idx, target_deck);
                    });
                } else {
                    play_track_to_deck(s.clone(), idx, target_deck);
                }
                true
            });
        }
        frame.add_controller(drop_target);

        // DropTarget para gio::File (archivos desde Nemo - igual que botonera)
        let drop_file = DropTarget::new(gtk4::gio::File::static_type(), gdk4::DragAction::COPY);
        {
            let s = state.clone();
            let frame_ref2 = frame.clone();
            drop_file.connect_drop(move |_target, value, _x, _y| {
                let (pautaje_activo, target_deck) = {
                    let st = s.lock().unwrap();
                    let pautaje = st.commercials_playing || st.eventos_playing;
                    let deck = match st.active_deck { ActiveDeck::A => ActiveDeck::B, ActiveDeck::B => ActiveDeck::A };
                    (pautaje, deck)
                };

                if let Ok(file) = value.get::<gtk4::gio::File>() {
                    if let Some(path) = file.path() {
                        if !path.exists() { return false; }
                        let track = make_track_from_path(path);
                        let playlist_path = home_dir().join(".gradio/data/tmp/playlist4");
                        {
                            let mut st = s.lock().unwrap();
                            st.playlist.insert(0, track);
                            save_playlist_to_file(&st.playlist, &playlist_path);
                            st.playlist_version = st.playlist_version.wrapping_add(1);
                        }
                        if pautaje_activo {
                            let s2 = s.clone();
                            show_confirm_interrupt_dialog(&frame_ref2, move || {
                                interrupt_pautaje(&s2);
                                play_track_to_deck(s2.clone(), 0, target_deck);
                            });
                        } else {
                            play_track_to_deck(s.clone(), 0, target_deck);
                        }
                        return true;
                    }
                }
                false
            });
        }
        frame.add_controller(drop_file);
    }

    (frame, btn_stop_end)
}

// ─── Acciones de playback ────────────────────────────────────────────────────

fn action_play(st: &mut AppState, deck: ActiveDeck) {
    let pipeline = match deck {
        ActiveDeck::A => st.pipeline_a.as_ref(),
        ActiveDeck::B => st.pipeline_b.as_ref(),
    };
    if let Some(p) = pipeline {
        // Si veníamos de Paused, realinear current_track_start con la posición
        // real del pipeline. De lo contrario, tras una pausa larga (p.ej. 40 min)
        // el detector de drift wall-vs-pos dispararía un crossfade espurio.
        let was_paused = match deck {
            ActiveDeck::A => st.deck_a_state == PlaybackState::Paused,
            ActiveDeck::B => st.deck_b_state == PlaybackState::Paused,
        };
        if was_paused {
            let pos = pipeline_position(p).max(0.0);
            st.current_track_start = std::time::Instant::now()
                - std::time::Duration::from_secs_f64(pos);
            match deck {
                ActiveDeck::A => {
                    st.silence_since_a = None;
                    st.stuck_since_a   = None;
                    st.stuck_pos_a     = -1.0;
                }
                ActiveDeck::B => {
                    st.silence_since_b = None;
                    st.stuck_since_b   = None;
                    st.stuck_pos_b     = -1.0;
                }
            }
            st.vu_last_level_msg = std::time::Instant::now();
        }
        let _ = p.set_state(gst::State::Playing);
        match deck {
            ActiveDeck::A => st.deck_a_state = PlaybackState::Playing,
            ActiveDeck::B => st.deck_b_state = PlaybackState::Playing,
        }
        st.active_deck = deck;
    }
}

fn action_pause(st: &mut AppState, deck: ActiveDeck) {
    let pipeline = match deck {
        ActiveDeck::A => st.pipeline_a.as_ref(),
        ActiveDeck::B => st.pipeline_b.as_ref(),
    };
    if let Some(p) = pipeline {
        let _ = p.set_state(gst::State::Paused);
        match deck {
            ActiveDeck::A => st.deck_a_state = PlaybackState::Paused,
            ActiveDeck::B => st.deck_b_state = PlaybackState::Paused,
        }
    }
}

fn action_repeat(st: &mut AppState, deck: ActiveDeck) {
    // Seek al inicio del track activo en ese deck
    let pipeline = match deck {
        ActiveDeck::A => st.pipeline_a.as_ref(),
        ActiveDeck::B => st.pipeline_b.as_ref(),
    };
    if let Some(p) = pipeline {
        pipeline_seek(p, 0.0);
        let _ = p.set_state(gst::State::Playing);
    }
    // Cancelar cualquier crossfade pendiente para evitar que el timer lance el
    // siguiente tema mientras GStreamer actualiza la posición tras el seek (async).
    st.crossfade_triggered = false;
    st.current_track_start = std::time::Instant::now();
}

fn action_stop(st: &mut AppState, deck: ActiveDeck) {
    // Extraer el pipeline del Option (take) para tener ownership exclusivo
    let pipeline_opt = match deck {
        ActiveDeck::A => st.pipeline_a.take(),
        ActiveDeck::B => st.pipeline_b.take(),
    };
    if let Some(p) = pipeline_opt {
        match deck {
            ActiveDeck::A => st.deck_a_state = PlaybackState::Stopped,
            ActiveDeck::B => st.deck_b_state = PlaybackState::Stopped,
        }
        // Liberar completamente: Null → esperar → cortar bus → drop
        shutdown_pipeline(p);
        info!("Deck {:?}: pipeline liberado", deck);
    }
    // El slot ya quedó en None por el .take() — no hay pipeline huérfano
}

/// Carga un track en el deck indicado y devuelve si fue exitoso
fn load_track_to_deck(st: &mut AppState, deck: ActiveDeck, track_idx: usize) -> bool {
    if track_idx >= st.playlist.len() {
        return false;
    }
    let uri = path_to_uri(&st.playlist[track_idx].path);
    // Validar que la URI no sea vacía o inválida
    if uri == "file://" || uri.is_empty() {
        warn!("Deck {:?}: ruta vacía en playlist[{}], saltando", deck, track_idx);
        return false;
    }
    let vol = st.upvol;
    let title = st.playlist[track_idx].title.clone();

    // Liberar pipeline previo completamente (take + Null + drop)
    // action_stop hace .take() así que el slot queda en None antes de crear el nuevo
    action_stop(st, deck);

    // Verificar que el slot esté vacío antes de proceder
    let slot_empty = match deck {
        ActiveDeck::A => st.pipeline_a.is_none(),
        ActiveDeck::B => st.pipeline_b.is_none(),
    };
    if !slot_empty {
        error!("Deck {:?}: slot no quedó vacío después de stop, abortando carga", deck);
        return false;
    }

    info!("Deck {:?}: cargando '{}' desde {}", deck, title, uri);
    match build_pipeline(&uri, vol, None) {
        Ok(p) => {
            match deck {
                ActiveDeck::A => st.pipeline_a = Some(p),
                ActiveDeck::B => st.pipeline_b = Some(p),
            }
            info!("Deck {:?}: pipeline creado OK", deck);
            true
        }
        Err(e) => {
            error!("Deck {:?}: error al crear pipeline: {}", deck, e);
            false
        }
    }
}

/// Inicia reproducción de un track con lógica de hora, comerciales, eventos y crossfade
fn start_next_track(state: SharedState, next_index: usize) {
    let home = home_dir();
    let comerciales_path = home.join(".gradio/data/tmp/comercialeslist4");
    let eventos_path     = home.join(".gradio/data/tmp/eventos-esperalist");
    let playlist_path    = home.join(".gradio/data/tmp/playlist4");

    // 0. Verificar si el próximo ítem es "Hora"
    {
        let st = state.lock().unwrap();
        if let Some(track) = st.playlist.get(next_index) {
            let stem = track.path.file_stem()
                .and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
            if stem == "hora" || track.path.to_str().unwrap_or("") == "Hora" {
                // Sacar el ítem "Hora" de la lista y reproducir hora del sistema,
                // luego continuar con el siguiente track
                drop(st);
                remove_first_line_from_file(&playlist_path);
                {
                    let mut st2 = state.lock().unwrap();
                    if next_index < st2.playlist.len() {
                        st2.playlist.remove(next_index);
                        st2.playlist_version = st2.playlist_version.wrapping_add(1);
                    }
                }
                let state_h = state.clone();
                let after_idx = next_index; // mismo índice: la lista se desplazó
                glib::spawn_future_local(async move {
                    play_hora(&state_h).await;
                    start_next_track(state_h, after_idx);
                });
                return;
            }
        }
    }

    // 1. Verificar comerciales y/o eventos en espera
    let commercials = load_commercials(&comerciales_path);
    let eventos     = load_eventos(&eventos_path);

    if !commercials.is_empty() || !eventos.is_empty() {
        let state_cl = state.clone();
        glib::spawn_future_local(async move {
            play_commercials_then_track(state_cl, commercials, eventos, next_index).await;
        });
    } else {
        // Sin comerciales: verificar si el track es un stream de internet
        let (track_path, track_dur, track_title) = {
            let st = state.lock().unwrap();
            if let Some(t) = st.playlist.get(next_index) {
                (t.path.clone(), t.duration_secs, t.title.clone())
            } else {
                return;
            }
        };

        let uri = path_to_uri(&track_path);
        let is_stream = uri.starts_with("http://") || uri.starts_with("https://");

        if is_stream {
            // ── Stream de internet: reproducir como pipeline_comm con fade ──
            // No puede ir en deck A/B porque nunca manda EOS.
            remove_first_line_from_file(&home.join(".gradio/data/tmp/playlist4"));
            let state_s = state.clone();
            glib::spawn_future_local(async move {
                let upvol = state_s.lock().unwrap().upvol;
                let crossfade = state_s.lock().unwrap().crossfade_secs;
                let dur = track_dur;

                // Actualizar título en UI
                {
                    let mut st = state_s.lock().unwrap();
                    st.current_title = track_title.clone();
                    st.current_index = next_index;
                }

                // Fadeout del deck activo
                fadeout_active_deck(&state_s, crossfade).await;

                // Reproducir stream con tiempo límite (dur_secs) o 60s si dur=0
                let limit = if dur > 0.0 { dur } else { 60.0 };
                { state_s.lock().unwrap().stream_skip = false; }
                match build_pipeline(&uri, upvol, None) {
                    Ok(p) => {
                        let mut p = p;
                        let _ = p.set_state(gst::State::Playing);
                        // Esperar hasta 3s a que conecte
                        let mut ready = false;
                        for _ in 0..30 {
                            glib::timeout_future(Duration::from_millis(100)).await;
                            if let Some(bus) = p.bus() {
                                while let Some(msg) = bus.timed_pop(gst::ClockTime::ZERO) {
                                    use gst::MessageView;
                                    match msg.view() {
                                        MessageView::StateChanged(sc) => {
                                            if sc.current() == gst::State::Playing { ready = true; }
                                        }
                                        MessageView::Error(_) => { ready = false; break; }
                                        _ => {}
                                    }
                                }
                            }
                            if ready { break; }
                        }
                        if ready {
                            {
                                let mut st = state_s.lock().unwrap();
                                st.pipeline_comm = Some(p.clone());
                                st.comm_title = track_title.clone();
                            }
                            attach_level_watch(&p, state_s.clone());
                            // Stream con tiempo límite y reconexión automática en caso de caída
                            let ticks_total = (limit * 5.0) as u64;
                            let mut ticks_done = 0u64;
                            let mut reconnect_count = 0u32;
                            let mut reconnect_elapsed = 0.0f64;
                            'stream_pl: loop {
                                while ticks_done < ticks_total {
                                    glib::timeout_future(Duration::from_millis(200)).await;
                                    ticks_done += 1;
                                    if state_s.lock().unwrap().stream_skip { break 'stream_pl; }
                                    let had_error = p.bus().map_or(false, |bus| {
                                        let mut err = false;
                                        while let Some(msg) = bus.pop() {
                                            if let gst::MessageView::Error(e) = msg.view() {
                                                warn!("Stream playlist caído: {}", e.error());
                                                err = true;
                                            }
                                        }
                                        err
                                    });
                                    if had_error {
                                        if reconnect_count >= 10 || reconnect_elapsed >= 20.0 {
                                            warn!("Stream playlist: máximo reconexiones alcanzado ({}): {}", reconnect_count, uri);
                                            break 'stream_pl;
                                        }
                                        let _ = p.set_state(gst::State::Null);
                                        { state_s.lock().unwrap().pipeline_comm = None; }
                                        glib::timeout_future(Duration::from_secs(2)).await;
                                        ticks_done = ticks_done.saturating_add(10);
                                        reconnect_elapsed += 2.0;
                                        reconnect_count += 1;
                                        warn!("Stream playlist: reconectando intento {}/10: {}", reconnect_count, uri);
                                        match build_pipeline(&uri, upvol, None) {
                                            Ok(new_p) => {
                                                let _ = new_p.set_state(gst::State::Playing);
                                                let mut rdy = false;
                                                for _ in 0..15 {
                                                    glib::timeout_future(Duration::from_millis(200)).await;
                                                    ticks_done = ticks_done.saturating_add(1);
                                                    if let Some(bus) = new_p.bus() {
                                                        while let Some(msg) = bus.pop() {
                                                            use gst::MessageView;
                                                            match msg.view() {
                                                                MessageView::StateChanged(sc) if sc.current() == gst::State::Playing => { rdy = true; }
                                                                _ => {}
                                                            }
                                                        }
                                                    }
                                                    if rdy { break; }
                                                }
                                                if rdy {
                                                    attach_level_watch(&new_p, state_s.clone());
                                                    { state_s.lock().unwrap().pipeline_comm = Some(new_p.clone()); }
                                                    p = new_p;
                                                    info!("Stream playlist reconectado (intento {}): {}", reconnect_count, uri);
                                                } else {
                                                    warn!("Stream playlist no disponible en reconexión {}: {}", reconnect_count, uri);
                                                    let _ = new_p.set_state(gst::State::Null);
                                                    break 'stream_pl;
                                                }
                                            }
                                            Err(e) => { error!("Stream playlist reconexión error: {}", e); break 'stream_pl; }
                                        }
                                    }
                                }
                                break 'stream_pl;
                            }
                            let taken = state_s.lock().unwrap().pipeline_comm.take();
                            if let Some(p2) = taken { shutdown_pipeline(p2); }
                        } else {
                            warn!("Stream playlist no disponible: {}", uri);
                            shutdown_pipeline(p);
                        }
                    }
                    Err(e) => error!("Stream playlist error: {}", e),
                }

                // Avanzar al siguiente track
                let next = {
                    let st = state_s.lock().unwrap();
                    if st.playlist.is_empty() { return; }
                    (next_index) % st.playlist.len()
                };
                start_next_track(state_s, next);
            });
            return;
        }

        // ── Audio local: crossfade normal entre decks ──
        remove_first_line_from_file(&home.join(".gradio/data/tmp/playlist4"));

        let mut st = state.lock().unwrap();
        let upvol = st.upvol;
        let crossfade = st.crossfade_secs;

        let next_deck = if st.active_deck == ActiveDeck::A {
            ActiveDeck::B
        } else {
            ActiveDeck::A
        };

        // Loguear el track saliente con el tiempo real que estuvo al aire
        let outgoing_track_path = st.current_track_path.clone();
        {
            let outgoing_path = &outgoing_track_path;
            if *outgoing_path != PathBuf::new() {
                // Obtener posición real del pipeline saliente
                let real_secs = {
                    let pipe = match st.active_deck {
                        ActiveDeck::A => st.pipeline_a.as_ref(),
                        ActiveDeck::B => st.pipeline_b.as_ref(),
                    };
                    pipe.map(|p| pipeline_position(p)).unwrap_or(0.0)
                };
                if real_secs > 0.5 {
                    log_parrilla(outgoing_path, real_secs);
                    log_historico(outgoing_path);
                }
            }
        }
        st.current_index = next_index;
        st.crossfade_triggered = false;  // nuevo track: permitir crossfade automático
        // Registrar path e inicio del nuevo track
        let (new_title, new_path) = st.playlist.get(next_index)
            .map(|t| (t.title.clone(), t.path.clone()))
            .unwrap_or_default();
        if !new_title.is_empty() {
            st.current_title = new_title;
            st.current_track_path = new_path;
            st.current_track_start = std::time::Instant::now();
        }
        let loaded = load_track_to_deck(&mut st, next_deck, next_index);
        if !loaded {
            // Track inválido (ruta vacía/inexistente): saltar al siguiente
            warn!("start_next_track: track[{}] no pudo cargarse, saltando", next_index);
            if next_index < st.playlist.len() {
                st.playlist.remove(next_index);
                st.playlist_version = st.playlist_version.wrapping_add(1);
            }
            let skip_idx = next_index.min(st.playlist.len().saturating_sub(1));
            drop(st);
            start_next_track(state, skip_idx);
            return;
        }

        // Eliminar el track de la lista en memoria (el archivo ya se borró del disco arriba)
        if next_index < st.playlist.len() {
            st.playlist.remove(next_index);
            st.playlist_version = st.playlist_version.wrapping_add(1);
        }
        st.current_index = next_index.min(st.playlist.len().saturating_sub(1));

        let pipe_out = match st.active_deck {
            ActiveDeck::A => st.pipeline_a.clone(),
            ActiveDeck::B => st.pipeline_b.clone(),
        };
        let pipe_in = match next_deck {
            ActiveDeck::A => st.pipeline_a.clone(),
            ActiveDeck::B => st.pipeline_b.clone(),
        };

        let out_deck_for_fade = st.active_deck;
        st.active_deck = next_deck;

        // ── Pisador automático ────────────────────────────────────────────────
        // Clonar path antes del borrow mutable (borrow checker)
        let track_path_for_pisador = st.current_track_path.clone();
        let do_pisador = should_play_auto_pisador(&mut st, &track_path_for_pisador);
        let pisador_dir_auto = if do_pisador {
            let cfg = read_gradio_config();
            let hora_dir = pisador_dir_hora_actual();
            if !hora_dir.is_empty() { hora_dir } else { cfg.pisador_dir.clone() }
        } else {
            String::new()
        };
        drop(st);

        // Lanzar pisador en background después del crossfade
        if !pisador_dir_auto.is_empty() {
            let state_pis = state.clone();
            glib::spawn_future_local(async move {
                // Esperar a que arranque el nuevo deck antes de pisar
                glib::timeout_future(Duration::from_millis(800)).await;
                play_pisador_from_dir(&state_pis, &pisador_dir_auto).await;
            });
        }

        match (pipe_out, pipe_in) {
            (Some(pout), Some(pin)) => {
                // Si el track saliente es demasiado corto para el crossfade, o si el
                // saliente/entrante está en una "carpeta que no se funde" (ej. locuciones
                // de IA), arranque directo para no cortar inicio ni final.
                let dur_out = pipeline_duration(&pout);
                let sin_fundir = fundido_omitido(&read_gradio_config(), &outgoing_track_path, &track_path_for_pisador);
                if sin_fundir || (dur_out > 0.0 && dur_out <= crossfade + 1.0) {
                    // Arranque directo: sin crossfade
                    set_pipeline_volume(&pin, upvol);
                    let _ = pin.set_state(gst::State::Playing);
                    attach_level_watch(&pin, state.clone());
                    {
                        let mut st = state.lock().unwrap();
                        match out_deck_for_fade {
                            ActiveDeck::A => { st.pipeline_a = None; }
                            ActiveDeck::B => { st.pipeline_b = None; }
                        }
                        match st.active_deck {
                            ActiveDeck::A => st.deck_a_state = PlaybackState::Playing,
                            ActiveDeck::B => st.deck_b_state = PlaybackState::Playing,
                        }
                    }
                    shutdown_pipeline(pout);
                } else {
                    // Crossfade: do_crossfade arranca pipe_in en vol=0 internamente
                    attach_level_watch(&pin, state.clone());
                    do_crossfade(pout, pin, crossfade, upvol, state.clone(), out_deck_for_fade);
                }
            }
            (None, Some(pin)) => {
                set_pipeline_volume(&pin, upvol);
                let _ = pin.set_state(gst::State::Playing);
                attach_level_watch(&pin, state.clone());
                {
                    let mut st = state.lock().unwrap();
                    match st.active_deck {
                        ActiveDeck::A => st.deck_a_state = PlaybackState::Playing,
                        ActiveDeck::B => st.deck_b_state = PlaybackState::Playing,
                    }
                }
            }
            _ => {
                error!("No se pudo iniciar reproducción: pipeline no disponible");
            }
        }
    }
}

/// Reproduce un track específico hacia un deck destino con crossfade.
/// Borra el tema de playlist[idx] (ya se está reproduciendo).
/// Usado por doble-click y drag-a-deck.
fn play_track_to_deck(state: SharedState, idx: usize, target_deck: ActiveDeck) {
    let home = home_dir();
    let playlist_path = home.join(".gradio/data/tmp/playlist4");

    // Leer datos del track
    let (track_path, _track_dur, crossfade, upvol) = {
        let st = state.lock().unwrap();
        if idx >= st.playlist.len() { return; }
        (
            st.playlist[idx].path.clone(),
            st.playlist[idx].duration_secs,
            st.crossfade_secs,
            st.upvol,
        )
    };

    let outgoing_track_path;
    let (pipe_out, pipe_in) = {
        let mut st = state.lock().unwrap();

        // Loguear el track saliente con tiempo real antes de avanzar
        outgoing_track_path = st.current_track_path.clone();
        {
            let outgoing_path = &outgoing_track_path;
            if *outgoing_path != PathBuf::new() {
                let real_secs = {
                    let pipe = match st.active_deck {
                        ActiveDeck::A => st.pipeline_a.as_ref(),
                        ActiveDeck::B => st.pipeline_b.as_ref(),
                    };
                    pipe.map(|p| pipeline_position(p)).unwrap_or(0.0)
                };
                if real_secs > 0.5 {
                    log_parrilla(outgoing_path, real_secs);
                }
            }
        }

        // Guardar el título ANTES de borrar (para que la UI lo muestre correctamente)
        let playing_title = if idx < st.playlist.len() {
            st.playlist[idx].title.clone()
        } else {
            String::new()
        };

        // Cargar el track en el deck destino (limpia el flag de "detenido tras tema")
        match target_deck {
            ActiveDeck::A => st.stopped_after_a = false,
            ActiveDeck::B => st.stopped_after_b = false,
        }
        load_track_to_deck(&mut st, target_deck, idx);

        let pipe_out = match st.active_deck {
            ActiveDeck::A => st.pipeline_a.clone(),
            ActiveDeck::B => st.pipeline_b.clone(),
        };
        let pipe_in = match target_deck {
            ActiveDeck::A => st.pipeline_a.clone(),
            ActiveDeck::B => st.pipeline_b.clone(),
        };

        // Borrar el tema de la playlist en memoria y en disco
        if idx < st.playlist.len() {
            st.playlist.remove(idx);
        }
        // current_index apunta al siguiente en lista; el título real va en current_title
        st.current_index = idx.min(st.playlist.len().saturating_sub(1));
        st.current_title = playing_title;
        st.current_track_path = track_path.clone();
        st.current_track_start = std::time::Instant::now();
        save_playlist_to_file(&st.playlist, &playlist_path);
        st.playlist_version = st.playlist_version.wrapping_add(1);

        // El deck destino pasa a ser el activo
        st.active_deck = target_deck;

        (pipe_out, pipe_in)
    };

    // El deck saliente es el opuesto al target
    let out_deck = if target_deck == ActiveDeck::A { ActiveDeck::B } else { ActiveDeck::A };
    // Hacer crossfade
    match (pipe_out, pipe_in) {
        (Some(pout), Some(pin)) => {
            let dur_out = pipeline_duration(&pout);
            let sin_fundir = fundido_omitido(&read_gradio_config(), &outgoing_track_path, &track_path);
            if sin_fundir || (dur_out > 0.0 && dur_out <= crossfade + 1.0) {
                set_pipeline_volume(&pin, upvol);
                let _ = pin.set_state(gst::State::Playing);
                attach_level_watch(&pin, state.clone());
                {
                    let mut st = state.lock().unwrap();
                    match out_deck {
                        ActiveDeck::A => { st.pipeline_a = None; }
                        ActiveDeck::B => { st.pipeline_b = None; }
                    }
                    match st.active_deck {
                        ActiveDeck::A => st.deck_a_state = PlaybackState::Playing,
                        ActiveDeck::B => st.deck_b_state = PlaybackState::Playing,
                    }
                }
                shutdown_pipeline(pout);
            } else {
                attach_level_watch(&pin, state.clone());
                do_crossfade(pout, pin, crossfade, upvol, state.clone(), out_deck);
            }
        }
        (None, Some(pin)) => {
            // Primer track, arranque directo
            set_pipeline_volume(&pin, upvol);
            let _ = pin.set_state(gst::State::Playing);
            attach_level_watch(&pin, state.clone());
            {
                let mut st = state.lock().unwrap();
                match st.active_deck {
                    ActiveDeck::A => st.deck_a_state = PlaybackState::Playing,
                    ActiveDeck::B => st.deck_b_state = PlaybackState::Playing,
                }
            }
        }
        _ => error!("play_track_to_deck: pipeline no disponible"),
    }
}

/// Crossfade: fade out pipeline_out, fade in pipeline_in durante `secs` segundos
fn do_crossfade(
    pipe_out: gst::Pipeline,
    pipe_in: gst::Pipeline,
    secs: f64,
    target_vol: f64,
    state: SharedState,
    out_deck: ActiveDeck,
) {
    let steps = (secs * 20.0).max(1.0) as u32;
    let step_vol = target_vol / steps as f64;
    let counter = Arc::new(Mutex::new(0u32));
    let pipe_out_opt = Arc::new(Mutex::new(Some(pipe_out)));

    // Crear token de cancelación único para este crossfade.
    // Si llega un nuevo crossfade antes de que este termine, el token anterior
    // se reemplaza en AppState y este timer se detiene en el siguiente tick.
    let cancel = Arc::new(AtomicBool::new(false));
    {
        let mut st = state.lock().unwrap();
        // Cancelar cualquier crossfade previo
        st.crossfade_cancel.store(true, Ordering::Relaxed);
        st.crossfade_cancel = cancel.clone();
    }

    // Poner pipe_in en Playing con vol=0 aquí dentro — garantiza orden correcto
    set_pipeline_volume(&pipe_in, 0.0);
    let _ = pipe_in.set_state(gst::State::Playing);
    {
        let mut st = state.lock().unwrap();
        match st.active_deck {
            ActiveDeck::A => st.deck_a_state = PlaybackState::Playing,
            ActiveDeck::B => st.deck_b_state = PlaybackState::Playing,
        }
    }

    timeout_add_local(Duration::from_millis(50), move || {
        // Si fue cancelado por un crossfade más nuevo, salir limpiamente
        if cancel.load(Ordering::Relaxed) {
            // Shutdown inmediato del pipe saliente: ya no es nuestro
            if let Some(p) = pipe_out_opt.lock().unwrap().take() {
                // Solo limpiar el slot si aún contiene ESTE pipeline saliente.
                // Un crossfade más nuevo puede haber cargado ya un pipeline entrante
                // en ese mismo slot; limpiarlo sin verificar lo mataría en PLAYING.
                {
                    let mut st = state.lock().unwrap();
                    let same = match out_deck {
                        ActiveDeck::A => st.pipeline_a.as_ref().map_or(false, |x| x == &p),
                        ActiveDeck::B => st.pipeline_b.as_ref().map_or(false, |x| x == &p),
                    };
                    if same {
                        match out_deck {
                            ActiveDeck::A => { st.pipeline_a = None; }
                            ActiveDeck::B => { st.pipeline_b = None; }
                        }
                    }
                }
                shutdown_pipeline(p);
            }
            return glib::ControlFlow::Break;
        }

        let mut c = counter.lock().unwrap();
        *c += 1;

        let fade_out_vol = (target_vol - step_vol * (*c) as f64).max(0.0);
        let fade_in_vol  = (step_vol * (*c) as f64).min(target_vol);

        if let Some(p) = pipe_out_opt.lock().unwrap().as_ref() {
            set_pipeline_volume(p, fade_out_vol);
        }
        set_pipeline_volume(&pipe_in, fade_in_vol);

        if *c >= steps {
            // Limpiar slot ANTES del shutdown, solo si aún es nuestro pipeline saliente
            if let Some(p) = pipe_out_opt.lock().unwrap().as_ref() {
                let mut st = state.lock().unwrap();
                let same = match out_deck {
                    ActiveDeck::A => st.pipeline_a.as_ref().map_or(false, |x| x == p),
                    ActiveDeck::B => st.pipeline_b.as_ref().map_or(false, |x| x == p),
                };
                if same {
                    match out_deck {
                        ActiveDeck::A => { st.pipeline_a = None; }
                        ActiveDeck::B => { st.pipeline_b = None; }
                    }
                }
            }
            if let Some(p) = pipe_out_opt.lock().unwrap().take() {
                shutdown_pipeline(p);
            }
            glib::ControlFlow::Break
        } else {
            glib::ControlFlow::Continue
        }
    });
}

/// Devuelve la lista de archivos de audio que representan la hora actual.
/// - Hora exacta (minuto==0): [HRSXX_O.mp3]
/// - Resto: [HRSXX.mp3, MINYY.mp3]
fn hora_audio_paths(time_dir: &std::path::Path) -> Vec<PathBuf> {
    let now = Local::now();
    let h = now.hour();   // 0-23
    let m = now.minute(); // 0-59
    if m == 0 {
        // Hora exacta
        let f = time_dir.join(format!("HRS{:02}_O.mp3", h));
        if f.exists() { vec![f] } else { vec![] }
    } else {
        let fh = time_dir.join(format!("HRS{:02}.mp3", h));
        let fm = time_dir.join(format!("MIN{:02}.mp3", m));
        let mut v = Vec::new();
        if fh.exists() { v.push(fh); }
        if fm.exists() { v.push(fm); }
        v
    }
}

/// Reproduce un pisador aleatorio desde la carpeta configurada en gradio.config.
/// Usa pipeline_comm a volumen 100% independiente del upvol musical.
async fn play_pisador(state: &SharedState, _home: &PathBuf) {
    let cfg = read_gradio_config();
    if cfg.pisador_dir.is_empty() {
        error!("Pisador: carpeta no configurada en gradio.config");
        return;
    }
    play_pisador_from_dir(state, &cfg.pisador_dir).await;
}

/// Núcleo: elige un audio aleatorio de `dir_str` y lo reproduce como pisador.
async fn play_pisador_from_dir(state: &SharedState, dir_str: &str) {
    let dir = PathBuf::from(dir_str);
    if !dir.exists() { error!("Pisador: directorio no existe: {:?}", dir); return; }

    let entries: Vec<PathBuf> = match fs::read_dir(&dir) {
        Ok(rd) => rd
            .filter_map(|e| e.ok())
            .map(|e| e.path())
            .filter(|p| p.extension()
                .and_then(|x| x.to_str())
                .map(|ext| matches!(ext.to_lowercase().as_str(), "mp3" | "wav" | "ogg" | "flac" | "m4a"))
                .unwrap_or(false))
            .collect(),
        Err(e) => { error!("Pisador: error leyendo directorio {:?}: {}", dir, e); return; }
    };
    if entries.is_empty() { error!("Pisador: no hay audios en {:?}", dir); return; }

    let chosen = match entries.choose(&mut rand::thread_rng()) {
        Some(p) => p.clone(),
        None => return,
    };

    let uri = path_to_uri(&chosen);
    info!("Pisador: '{}'", chosen.file_name().unwrap_or_default().to_string_lossy());

    let old = state.lock().unwrap().pipeline_comm.take();
    if let Some(p) = old { shutdown_pipeline(p); }

    let pipeline: gst::Pipeline = match build_pipeline(&uri, 1.0, None) {
        Ok(p) => p,
        Err(e) => { error!("Pisador: error pipeline: {}", e); return; }
    };

    let title = chosen.file_stem().unwrap_or_default().to_string_lossy().to_string();
    {
        let mut st = state.lock().unwrap();
        st.pipeline_comm = Some(pipeline.clone());
        st.comm_title = format!("🎯 {}", title);
    }

    let _ = pipeline.set_state(gst::State::Playing);
    attach_level_watch_comm(&pipeline, state.clone());
    wait_pipeline_eos(&pipeline, state).await;

    let taken = state.lock().unwrap().pipeline_comm.take();
    if let Some(p) = taken { shutdown_pipeline(p); }
}

/// Reproduce un archivo específico en el pipeline de comerciales/pisador (sin tocar la playlist).
async fn play_botonera_file(state: &SharedState, ruta: &str) {
    let path = PathBuf::from(ruta);
    if !path.exists() {
        error!("Botonera remota: archivo no existe: {}", ruta);
        return;
    }
    let uri = path_to_uri(&path);
    info!("Botonera remota: '{}'", path.file_name().unwrap_or_default().to_string_lossy());

    // Apagar botonera previa si había otra en curso.
    // NO se toca pipeline_comm — el comercial/parrilla sigue reproduciendo sin interrupción.
    let old = state.lock().unwrap().pipeline_botonera.take();
    if let Some(p) = old { shutdown_pipeline(p); }

    let upvol = state.lock().unwrap().upvol;
    let pipeline = match build_pipeline(&uri, upvol, None) {
        Ok(p) => p,
        Err(e) => { error!("Botonera remota: error pipeline: {}", e); return; }
    };

    let title = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
    {
        let mut st = state.lock().unwrap();
        st.pipeline_botonera = Some(pipeline.clone());
        st.comm_title = format!("🎛 {}", title);
    }

    let _ = pipeline.set_state(gst::State::Playing);

    // Esperar EOS con loop propio — no depende de abort_pautaje para que los
    // comerciales puedan interrumpirse independientemente de la botonera.
    let bus = pipeline.bus().unwrap();
    'eos: loop {
        glib::timeout_future(Duration::from_millis(50)).await;
        while let Some(msg) = bus.timed_pop(gst::ClockTime::ZERO) {
            use gst::MessageView;
            match msg.view() {
                MessageView::Eos(_) => break 'eos,
                MessageView::Error(e) => {
                    error!("Botonera: pipeline error: {}", e.error());
                    break 'eos;
                }
                _ => {}
            }
        }
        // Si otra botonera arrancó y ya limpió pipeline_botonera, salir
        if state.lock().unwrap().pipeline_botonera.is_none() { break 'eos; }
    }

    let taken = state.lock().unwrap().pipeline_botonera.take();
    if let Some(p) = taken { shutdown_pipeline(p); }
}

/// Verifica si se debe tocar pisador automático para el track en `track_path`.
/// Incrementa el contador y dispara si corresponde, respetando exclusiones.
fn should_play_auto_pisador(st: &mut AppState, track_path: &Path) -> bool {
    let cfg = read_gradio_config();
    if !cfg.pisador_enabled || cfg.pisador_dir.is_empty() { return false; }
    // Verificar si el track actual está en una carpeta excluida
    let track_dir = track_path.parent()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_default();
    for excl in &cfg.pisador_exclude {
        let excl_norm = excl.trim_end_matches('/');
        let track_norm = track_dir.trim_end_matches('/');
        if track_norm == excl_norm || track_norm.starts_with(&format!("{}/", excl_norm)) {
            info!("Pisador: omitido — carpeta excluida: {}", track_dir);
            return false;
        }
    }
    st.pisador_track_count += 1;
    if st.pisador_track_count >= cfg.pisador_every {
        st.pisador_track_count = 0;
        return true;
    }
    false
}

/// Verifica si `track_path` está dentro de alguna carpeta de `list` (comparación
/// exacta de carpeta o subcarpeta, normalizando la barra final).
fn path_en_lista_carpetas(track_path: &Path, list: &[String]) -> bool {
    if list.is_empty() { return false; }
    let track_dir = track_path.parent()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_default();
    let track_norm = track_dir.trim_end_matches('/');
    list.iter().any(|carpeta| {
        let carpeta_norm = carpeta.trim_end_matches('/');
        track_norm == carpeta_norm || track_norm.starts_with(&format!("{}/", carpeta_norm))
    })
}

/// Verifica si el crossfade debe omitirse (arranque/salida directa) porque el
/// track saliente o el entrante pertenecen a una "carpeta que no se funde"
/// (ej. locuciones de IA: no queremos cortar su inicio ni su final).
fn fundido_omitido(cfg: &GradioConfig, saliente: &Path, entrante: &Path) -> bool {
    if cfg.fundido_exclude.is_empty() { return false; }
    let omitido = path_en_lista_carpetas(saliente, &cfg.fundido_exclude)
        || path_en_lista_carpetas(entrante, &cfg.fundido_exclude);
    if omitido {
        info!("Fundido omitido — carpeta sin crossfade: saliente={} entrante={}",
              saliente.display(), entrante.display());
    }
    omitido
}

/// Reproduce la hora del sistema sobre la música de forma asíncrona.
/// Baja la música al 80% del upvol para que la hora se escuche 20% más fuerte.
/// Detecta duración real de cada archivo para eliminar el gap entre hora y minuto.
async fn play_hora(state: &SharedState) {
    let home = home_dir();
    let time_dir = home.join(".gradio/data/panel/Time");
    let paths = hora_audio_paths(&time_dir);
    if paths.is_empty() {
        warn!("play_hora: no se encontraron archivos en {}", time_dir.display());
        return;
    }

    // Duck: bajar el deck activo al 80% del upvol
    let (active_pipe, upvol) = {
        let st = state.lock().unwrap();
        let p = match st.active_deck {
            ActiveDeck::A => st.pipeline_a.clone(),
            ActiveDeck::B => st.pipeline_b.clone(),
        };
        (p, st.upvol)
    };
    let ducked_vol = upvol * 0.8;
    if let Some(ref p) = active_pipe {
        set_pipeline_volume(p, ducked_vol);
    }

    for path in paths {
        let uri = path_to_uri(&path);
        let dur = {
            let timeout = gst::ClockTime::from_seconds(5);
            gst_pbutils::Discoverer::new(timeout)
                .ok()
                .and_then(|d| d.discover_uri(&uri).ok())
                .map(|i| i.duration().map(|d| d.seconds_f64()).unwrap_or(0.0))
                .unwrap_or(0.0)
        };
        let old_pipe = { state.lock().unwrap().pipeline_comm.take() };
        if let Some(p) = old_pipe { shutdown_pipeline(p); }
        match build_pipeline(&uri, 1.0, None) {
            Ok(p) => {
                let _ = p.set_state(gst::State::Playing);
                {
                    let now = Local::now();
                    let label = format!("🕐 {:02}:{:02}", now.hour(), now.minute());
                    let mut st = state.lock().unwrap();
                    st.pipeline_comm = Some(p.clone());
                    st.comm_title = label;
                }
                attach_level_watch_comm(&p, state.clone());
                if dur > 0.1 {
                    glib::timeout_future(Duration::from_secs_f64(dur)).await;
                } else {
                    wait_pipeline_eos(&p, state).await;
                }
                let taken = state.lock().unwrap().pipeline_comm.take();
                if let Some(p2) = taken { shutdown_pipeline(p2); }
            }
            Err(e) => error!("play_hora pipeline error: {}", e),
        }
    }

    // Restaurar volumen del deck activo
    if let Some(ref p) = active_pipe {
        set_pipeline_volume(p, upvol);
    }
}

/// Fade out del deck activo en N pasos de 50ms y luego lo detiene.
async fn fadeout_active_deck(state: &SharedState, secs: f64) {
    let (pipe, vol) = {
        let st = state.lock().unwrap();
        let p = match st.active_deck {
            ActiveDeck::A => st.pipeline_a.clone(),
            ActiveDeck::B => st.pipeline_b.clone(),
        };
        (p, st.upvol)
    };
    if let Some(p) = pipe {
        let steps = (secs * 20.0).max(1.0) as u32;
        for i in 1..=steps {
            let v = (vol - vol / steps as f64 * i as f64).max(0.0);
            set_pipeline_volume(&p, v);
            glib::timeout_future(Duration::from_millis(50)).await;
        }
        // Limpiar slot ANTES del shutdown: refcount baja a 1 aquí → 0 en shutdown
        {
            let mut st = state.lock().unwrap();
            match st.active_deck {
                ActiveDeck::A => { st.pipeline_a = None; }
                ActiveDeck::B => { st.pipeline_b = None; }
            }
        }
        shutdown_pipeline(p);
    } else {
        // Sin pipeline activo: limpiar slot igualmente
        let mut st = state.lock().unwrap();
        match st.active_deck {
            ActiveDeck::A => { st.pipeline_a = None; }
            ActiveDeck::B => { st.pipeline_b = None; }
        }
    }
}

/// Reproduce la lista de eventos emergentes (eventoslist) de forma asíncrona.
/// Flujo: fadeout deck activo → eventos → comerciales → eventos en espera → siguiente tema.
async fn play_eventos_emergentes(state: SharedState) {
    let home = home_dir();
    let eventos_path  = home.join(".gradio/data/tmp/eventoslist");
    let espera_path   = home.join(".gradio/data/tmp/eventos-esperalist");
    let comerc_path   = home.join(".gradio/data/tmp/comercialeslist4");

    // Leer eventos — NO vaciamos de golpe, borramos línea a línea al reproducir
    let eventos = load_eventos(&eventos_path);
    if eventos.is_empty() {
        state.lock().unwrap().eventos_playing = false;
        return;
    }

    let upvol = state.lock().unwrap().upvol;
    let crossfade = state.lock().unwrap().crossfade_secs;

    // 1. Fadeout suave del deck activo
    fadeout_active_deck(&state, crossfade).await;

    // Helper interno reutilizado de play_commercials_then_track
    async fn play_audio(state: &SharedState, uri: &str, upvol: f64, dur: f64) -> f64 {
        let old = { state.lock().unwrap().pipeline_comm.take() };
        if let Some(p) = old { shutdown_pipeline(p); }
        let _decoded = percent_decode(uri.trim_start_matches("file://"));
        let comm_name = std::path::Path::new(&_decoded)
            .file_stem().and_then(|s| s.to_str()).unwrap_or("Audio").to_string();
        match build_pipeline(uri, upvol, None) {
            Ok(p) => {
                let _ = p.set_state(gst::State::Playing);
                {
                    let mut st = state.lock().unwrap();
                    st.pipeline_comm = Some(p.clone());
                    st.comm_title = comm_name.clone();
                }
                attach_level_watch_comm(&p, state.clone());
                info!("Evento emergente: {}", comm_name);
                if dur > 0.0 {
                    glib::timeout_future(Duration::from_secs_f64(dur + 0.3)).await;
                } else {
                    wait_pipeline_eos(&p, state).await;
                }
                let real_secs = pipeline_position(&p);
                let taken = state.lock().unwrap().pipeline_comm.take();
                if let Some(p2) = taken { shutdown_pipeline(p2); }
                real_secs
            }
            Err(e) => { error!("Evento pipeline error: {}", e); 0.0 }
        }
    }

    // 2. Reproducir cada evento emergente, borrando línea a línea
    for evento in &eventos {
        if state.lock().unwrap().abort_pautaje { break; }
        if let Some(ref url) = evento.url {
            remove_first_line_from_file(&eventos_path);
            info!("Stream emergente: {} ({}s)", url, evento.duration_secs);
            play_comm_audio_top(&state, url, upvol, evento.duration_secs, true, &mut None).await;
        } else {
            let ext = evento.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
            let stem = evento.path.file_stem()
                .and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
            if ext == "gradio" {
                // Lista .gradio: reemplazar playlist completa
                remove_first_line_from_file(&eventos_path);
                apply_gradio_file(&state, &evento.path, None);
            } else if stem == "hora" {
                remove_first_line_from_file(&eventos_path);
                play_hora(&state).await;
            } else {
                let resolved = if evento.path.is_dir() {
                    random_file_from_dir(&evento.path)
                } else if evento.path.exists() {
                    Some(evento.path.clone())
                } else {
                    None
                };
                if let Some(file) = resolved {
                    remove_first_line_from_file(&eventos_path);
                    let uri = path_to_uri(&file);
                    let real_secs = play_audio(&state, &uri, upvol, evento.duration_secs).await;
                    log_evento(&file, if real_secs > 0.5 { real_secs } else { evento.duration_secs });
                } else {
                    remove_first_line_from_file(&eventos_path);
                    warn!("Evento emergente no encontrado: {}", evento.path.display());
                }
            }
        }
    }

    // 3. Limpiar pipeline_comm
    {
        let old = { state.lock().unwrap().pipeline_comm.take() };
        if let Some(p) = old { shutdown_pipeline(p); }
    }

    // 4. Comerciales (si hay) — misma lógica que play_commercials_then_track
    //    pero SIN arrancar el track al final (eso lo hace start_next_track abajo)
    let upvol = state.lock().unwrap().upvol; // re-leer por si cambió
    let commercials = load_commercials(&comerc_path);
    info!("Eventos emergentes: {} comerciales pendientes", commercials.len());

    if !commercials.is_empty() {
        let home2 = home_dir();
        let inicio_dir = home2.join("G Radio/inicio-espacio-pub");
        let fin_dir    = home2.join("G Radio/fin-espacio-pub");

        // Cada comercial — jingle de inicio antes del primero que realmente suene
        let mut comerciales_rep: u32 = 0;
        for commercial in &commercials {
            if let Some(ref url) = commercial.url {
                remove_first_line_from_file(&comerc_path);
                if comerciales_rep == 0 {
                    if let Some(f) = random_file_from_dir(&inicio_dir) {
                        play_comm_audio_top(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await;
                    }
                }
                comerciales_rep += 1;
                play_comm_audio_top(&state, url, upvol, commercial.duration_secs, true, &mut None).await;
            } else {
                let ext_c = commercial.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
                let stem = commercial.path.file_stem()
                    .and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
                if ext_c == "gradio" {
                    // Lista .gradio en comerciales: reemplazar playlist completa
                    remove_first_line_from_file(&comerc_path);
                    apply_gradio_file(&state, &commercial.path, None);
                    continue;
                } else if stem == "hora" {
                    remove_first_line_from_file(&comerc_path);
                    if comerciales_rep == 0 {
                        if let Some(f) = random_file_from_dir(&inicio_dir) {
                            play_comm_audio_top(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await;
                        }
                    }
                    comerciales_rep += 1;
                    play_hora(&state).await;
                } else {
                    let resolved = if commercial.path.is_dir() {
                        random_file_from_dir(&commercial.path)
                    } else if commercial.path.exists() {
                        Some(commercial.path.clone())
                    } else {
                        None
                    };
                    if let Some(file) = resolved {
                        remove_first_line_from_file(&comerc_path);
                        if comerciales_rep == 0 {
                            if let Some(f) = random_file_from_dir(&inicio_dir) {
                                play_comm_audio_top(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await;
                            }
                        }
                        comerciales_rep += 1;
                        let uri = path_to_uri(&file);
                        let mut real_secs_c: Option<f64> = None;
                        play_comm_audio_top(&state, &uri, upvol, commercial.duration_secs, false, &mut real_secs_c).await;
                        log_comercial(&file, real_secs_c.unwrap_or(commercial.duration_secs));
                    } else {
                        remove_first_line_from_file(&comerc_path);
                        warn!("Comercial no encontrado: {}", commercial.path.display());
                    }
                }
            }
        }

        // Jingle de fin: solo si al menos uno sonó
        if comerciales_rep > 0 {
            if let Some(f) = random_file_from_dir(&fin_dir) {
                play_comm_audio_top(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await;
            }
        }
    }

    // 5. Eventos en espera (si hay)
    let espera = load_eventos(&espera_path);
    info!("Eventos emergentes: {} eventos en espera pendientes", espera.len());

    for evento in &espera {
        if let Some(ref url) = evento.url {
            remove_first_line_from_file(&espera_path);
            play_comm_audio_top(&state, url, upvol, evento.duration_secs, true, &mut None).await;
        } else {
            let ext_e2 = evento.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
            let stem = evento.path.file_stem()
                .and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
            if ext_e2 == "gradio" {
                // .gradio en eventos-espera: insertar en la posición actual del playlist
                remove_first_line_from_file(&espera_path);
                let insert_at = state.lock().unwrap().current_index;
                apply_gradio_file(&state, &evento.path, Some(insert_at));
            } else if stem == "hora" {
                remove_first_line_from_file(&espera_path);
                play_hora(&state).await;
            } else {
                let resolved = if evento.path.is_dir() {
                    random_file_from_dir(&evento.path)
                } else if evento.path.exists() {
                    Some(evento.path.clone())
                } else {
                    None
                };
                if let Some(file) = resolved {
                    remove_first_line_from_file(&espera_path);
                    let uri = path_to_uri(&file);
                    let mut real_secs_e: Option<f64> = None;
                    play_comm_audio_top(&state, &uri, upvol, evento.duration_secs, false, &mut real_secs_e).await;
                    log_evento(&file, real_secs_e.unwrap_or(evento.duration_secs));
                } else {
                    remove_first_line_from_file(&espera_path);
                    warn!("Evento en espera no encontrado: {}", evento.path.display());
                }
            }
        }
    }

    // Limpiar pipeline_comm final
    {
        let old = state.lock().unwrap().pipeline_comm.take();
        if let Some(p) = old { shutdown_pipeline(p); }
    }

    // 6. Retomar playlist
    {
        let mut st = state.lock().unwrap();
        if st.abort_pautaje {
            st.abort_pautaje = false;
            st.eventos_playing = false;
            st.commercials_playing = false;
            st.crossfade_triggered = false;
            return;
        }
        st.eventos_playing = false;
    }
    let next_idx = state.lock().unwrap().current_index;
    start_next_track(state.clone(), next_idx);
}

/// Versión top-level de play_comm_audio para usar fuera de play_commercials_then_track.
/// Idéntica lógica pero accesible como función libre async.
async fn play_comm_audio_top(state: &SharedState, uri: &str, upvol: f64, dur_hint: f64, is_stream: bool, real_secs_out: &mut Option<f64>) -> bool {
    let dur_hint = if is_stream && dur_hint < 0.1 {
        warn!("Stream sin duración declarada, usando 60s por defecto: {}", uri);
        60.0
    } else { dur_hint };
    if state.lock().unwrap().abort_pautaje { return false; }
    { state.lock().unwrap().stream_skip = false; }
    let old = { state.lock().unwrap().pipeline_comm.take() };
    if let Some(p) = old { shutdown_pipeline(p); }

    let _dec = percent_decode(uri.trim_start_matches("file://"));
    let comm_name = if is_stream {
        uri.to_string()
    } else {
        std::path::Path::new(&_dec)
            .file_stem().and_then(|s| s.to_str()).unwrap_or("Audio").to_string()
    };

    match build_pipeline(uri, upvol, None) {
        Ok(p) => {
            let mut p = p;
            let _ = p.set_state(gst::State::Playing);

            if is_stream {
                let mut ready = false;
                for _ in 0..30 {
                    glib::timeout_future(Duration::from_millis(100)).await;
                    let bus = p.bus().unwrap();
                    let mut got_error = false;
                    while let Some(msg) = bus.pop() {
                        use gst::MessageView;
                        match msg.view() {
                            MessageView::StateChanged(sc) => {
                                if sc.current() == gst::State::Playing { ready = true; }
                            }
                            MessageView::Error(_) => { got_error = true; }
                            _ => {}
                        }
                    }
                    if ready || got_error { break; }
                }
                if !ready {
                    warn!("Stream no disponible: {}", uri);
                    shutdown_pipeline(p);
                    return false;
                }
            }

            {
                let mut st = state.lock().unwrap();
                st.pipeline_comm = Some(p.clone());
                st.comm_title = comm_name.clone();
            }
            attach_level_watch_comm(&p, state.clone());
            info!("Reproduciendo (top): {}", comm_name);

            if is_stream && dur_hint > 0.0 {
                // Stream con tiempo límite y reconexión automática en caso de caída
                let ticks_total = (dur_hint * 5.0) as u64;
                let mut ticks_done = 0u64;
                let mut reconnect_count = 0u32;
                let mut reconnect_elapsed = 0.0f64;
                'stream_top: loop {
                    while ticks_done < ticks_total {
                        glib::timeout_future(Duration::from_millis(200)).await;
                        ticks_done += 1;
                        if state.lock().unwrap().stream_skip { break 'stream_top; }
                        if state.lock().unwrap().abort_pautaje { break 'stream_top; }
                        // Detectar caída del stream (error en el bus)
                        let had_error = p.bus().map_or(false, |bus| {
                            let mut err = false;
                            while let Some(msg) = bus.pop() {
                                if let gst::MessageView::Error(e) = msg.view() {
                                    warn!("Stream caído durante reproducción: {}", e.error());
                                    err = true;
                                }
                            }
                            err
                        });
                        if had_error {
                            if reconnect_count >= 10 || reconnect_elapsed >= 20.0 {
                                warn!("Stream: máximo de reconexiones alcanzado ({}), abandonando: {}", reconnect_count, uri);
                                break 'stream_top;
                            }
                            let _ = p.set_state(gst::State::Null);
                            { state.lock().unwrap().pipeline_comm = None; }
                            // Esperar 2 segundos antes de reconectar
                            glib::timeout_future(Duration::from_secs(2)).await;
                            ticks_done = ticks_done.saturating_add(10);
                            reconnect_elapsed += 2.0;
                            reconnect_count += 1;
                            warn!("Stream: reconectando intento {}/10: {}", reconnect_count, uri);
                            match build_pipeline(uri, upvol, None) {
                                Ok(new_p) => {
                                    let _ = new_p.set_state(gst::State::Playing);
                                    let mut ready = false;
                                    for _ in 0..15 {
                                        glib::timeout_future(Duration::from_millis(200)).await;
                                        ticks_done = ticks_done.saturating_add(1);
                                        if let Some(bus) = new_p.bus() {
                                            while let Some(msg) = bus.pop() {
                                                use gst::MessageView;
                                                match msg.view() {
                                                    MessageView::StateChanged(sc) if sc.current() == gst::State::Playing => { ready = true; }
                                                    _ => {}
                                                }
                                            }
                                        }
                                        if ready { break; }
                                    }
                                    if ready {
                                        attach_level_watch_comm(&new_p, state.clone());
                                        { state.lock().unwrap().pipeline_comm = Some(new_p.clone()); }
                                        p = new_p;
                                        info!("Stream reconectado (intento {}): {}", reconnect_count, uri);
                                    } else {
                                        warn!("Stream no disponible en reconexión {}: {}", reconnect_count, uri);
                                        let _ = new_p.set_state(gst::State::Null);
                                        break 'stream_top;
                                    }
                                }
                                Err(e) => { error!("Stream reconexión build error: {}", e); break 'stream_top; }
                            }
                        }
                    }
                    break 'stream_top;
                }
            } else if dur_hint > 0.0 {
                let ticks = ((dur_hint + 0.3) * 5.0) as u64;
                for _ in 0..ticks {
                    glib::timeout_future(Duration::from_millis(200)).await;
                    if state.lock().unwrap().abort_pautaje { break; }
                }
            } else {
                wait_pipeline_eos(&p, state).await;
            }

            let real_secs = pipeline_position(&p);
            let taken = state.lock().unwrap().pipeline_comm.take();
            if let Some(p2) = taken { shutdown_pipeline(p2); }
            real_secs_out.replace(real_secs);
            true
        }
        Err(e) => {
            error!("Pipeline error top ({}): {}", uri, e);
            false
        }
    }
}

/// Reproduce comerciales y/o eventos en espera de forma asíncrona, luego inicia el track.
/// Orden: jingle-inicio → comerciales → jingle-fin → eventos → track musical.
/// Si no hay comerciales, omite jingles de inicio/fin.
async fn play_commercials_then_track(
    state: SharedState,
    #[allow(dead_code)]
    commercials: Vec<Commercial>,
    eventos: Vec<Commercial>,
    track_index: usize,
) {
    info!("play_commercials_then_track: {} comerciales, {} espera, track_index={}",
        commercials.len(), eventos.len(), track_index);
    // Bloquear el timer de crossfade mientras gestionamos el corte
    { state.lock().unwrap().commercials_playing = true; }
    let home = home_dir();
    let inicio_dir = home.join("G Radio/inicio-espacio-pub");
    let fin_dir = home.join("G Radio/fin-espacio-pub");

    // Determinar deck libre (el que no está activo)
    let (_comm_deck, upvol) = {
        let st = state.lock().unwrap();
        let deck = if st.active_deck == ActiveDeck::A {
            ActiveDeck::B
        } else {
            ActiveDeck::A
        };
        (deck, st.upvol)
    };

    // Helper: reproducir un audio/stream en pipeline_comm y esperar su fin.
    // Para URLs: usa dur_hint como límite de tiempo y respeta stream_skip.
    // Retorna false si el stream no estaba disponible (para saltar al siguiente).
    async fn play_comm_audio(state: &SharedState, uri: &str, upvol: f64, dur_hint: f64, is_stream: bool, real_secs_out: &mut Option<f64>) -> bool {
        let dur_hint = if is_stream && dur_hint < 0.1 {
            warn!("Stream sin duración declarada, usando 60s por defecto: {}", uri);
            60.0
        } else { dur_hint };
        // Abortar inmediatamente si la señal de interrupción está activa
        if state.lock().unwrap().abort_pautaje { return false; }
        // Resetear señal de skip al inicio de cada audio
        { state.lock().unwrap().stream_skip = false; }

        // Apagar cualquier pipeline_comm previo
        let old = { state.lock().unwrap().pipeline_comm.take() };
        if let Some(p) = old { shutdown_pipeline(p); }

        // Nombre para mostrar en UI (decodificar %20, etc. del URI de GStreamer)
        let _dec2 = percent_decode(uri.trim_start_matches("file://"));
        let comm_name = if is_stream {
            uri.to_string()
        } else {
            std::path::Path::new(&_dec2)
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("Audio")
                .to_string()
        };

        match build_pipeline(uri, upvol, None) {
            Ok(p) => {
                let mut p = p;
                let _ = p.set_state(gst::State::Playing);

                // Para streams: esperar hasta 3s a que arranque (PLAYING o ERROR)
                if is_stream {
                    let mut ready = false;
                    for _ in 0..30 {
                        glib::timeout_future(Duration::from_millis(100)).await;
                        let bus = p.bus().unwrap();
                        let mut got_error = false;
                        while let Some(msg) = bus.pop() {
                            use gst::MessageView;
                            match msg.view() {
                                MessageView::StateChanged(sc) => {
                                    if sc.current() == gst::State::Playing {
                                        ready = true;
                                    }
                                }
                                MessageView::Error(_) => { got_error = true; }
                                _ => {}
                            }
                        }
                        if ready || got_error { break; }
                    }
                    if !ready {
                        warn!("Stream no disponible, saltando: {}", uri);
                        shutdown_pipeline(p);
                        return false;
                    }
                }

                {
                    let mut st = state.lock().unwrap();
                    st.pipeline_comm = Some(p.clone());
                    st.comm_title = comm_name.clone();
                }
                attach_level_watch_comm(&p, state.clone());
                info!("Reproduciendo: {}", comm_name);

                if is_stream && dur_hint > 0.0 {
                    // Stream con tiempo límite y reconexión automática en caso de caída
                    let ticks_total = (dur_hint * 5.0) as u64;
                    let mut ticks_done = 0u64;
                    let mut reconnect_count = 0u32;
                    let mut reconnect_elapsed = 0.0f64;
                    'stream_comm: loop {
                        while ticks_done < ticks_total {
                            glib::timeout_future(Duration::from_millis(200)).await;
                            ticks_done += 1;
                            if state.lock().unwrap().stream_skip {
                                info!("Stream interrumpido por usuario: {}", uri);
                                break 'stream_comm;
                            }
                            if state.lock().unwrap().abort_pautaje { break 'stream_comm; }
                            // Detectar caída del stream (error en el bus)
                            let had_error = p.bus().map_or(false, |bus| {
                                let mut err = false;
                                while let Some(msg) = bus.pop() {
                                    if let gst::MessageView::Error(e) = msg.view() {
                                        warn!("Stream caído durante reproducción: {}", e.error());
                                        err = true;
                                    }
                                }
                                err
                            });
                            if had_error {
                                if reconnect_count >= 10 || reconnect_elapsed >= 20.0 {
                                    warn!("Stream: máximo de reconexiones alcanzado ({}), abandonando: {}", reconnect_count, uri);
                                    break 'stream_comm;
                                }
                                let _ = p.set_state(gst::State::Null);
                                { state.lock().unwrap().pipeline_comm = None; }
                                // Esperar 2 segundos antes de reconectar
                                glib::timeout_future(Duration::from_secs(2)).await;
                                ticks_done = ticks_done.saturating_add(10);
                                reconnect_elapsed += 2.0;
                                reconnect_count += 1;
                                warn!("Stream: reconectando intento {}/10: {}", reconnect_count, uri);
                                match build_pipeline(uri, upvol, None) {
                                    Ok(new_p) => {
                                        let _ = new_p.set_state(gst::State::Playing);
                                        let mut ready = false;
                                        for _ in 0..15 {
                                            glib::timeout_future(Duration::from_millis(200)).await;
                                            ticks_done = ticks_done.saturating_add(1);
                                            if let Some(bus) = new_p.bus() {
                                                while let Some(msg) = bus.pop() {
                                                    use gst::MessageView;
                                                    match msg.view() {
                                                        MessageView::StateChanged(sc) if sc.current() == gst::State::Playing => { ready = true; }
                                                        _ => {}
                                                    }
                                                }
                                            }
                                            if ready { break; }
                                        }
                                        if ready {
                                            attach_level_watch_comm(&new_p, state.clone());
                                            { state.lock().unwrap().pipeline_comm = Some(new_p.clone()); }
                                            p = new_p;
                                            info!("Stream reconectado (intento {}): {}", reconnect_count, uri);
                                        } else {
                                            warn!("Stream no disponible en reconexión {}: {}", reconnect_count, uri);
                                            let _ = new_p.set_state(gst::State::Null);
                                            break 'stream_comm;
                                        }
                                    }
                                    Err(e) => { error!("Stream reconexión build error: {}", e); break 'stream_comm; }
                                }
                            }
                        }
                        break 'stream_comm;
                    }
                } else if dur_hint > 0.0 {
                    let ticks = ((dur_hint + 0.3) * 5.0) as u64;
                    for _ in 0..ticks {
                        glib::timeout_future(Duration::from_millis(200)).await;
                        if state.lock().unwrap().abort_pautaje { break; }
                    }
                } else {
                    wait_pipeline_eos(&p, state).await;
                }

                // Limpiar
                let real_secs = pipeline_position(&p);
                let taken = state.lock().unwrap().pipeline_comm.take();
                if let Some(p2) = taken { shutdown_pipeline(p2); }
                real_secs_out.replace(real_secs);
                true
            }
            Err(e) => {
                error!("Pipeline error ({}): {}", uri, e);
                false
            }
        }
    }

    // ── Reproducir cada comercial (sin fundido) ──
    // Jingle de inicio: solo antes del primer comercial que realmente se reproduzca
    let comerciales_path_loop = home.join(".gradio/data/tmp/comercialeslist4");
    let mut comerciales_reproducidos: u32 = 0;
    for commercial in &commercials {
        if state.lock().unwrap().abort_pautaje { break; }
        if let Some(ref url) = commercial.url {
            remove_first_line_from_file(&comerciales_path_loop);
            if comerciales_reproducidos == 0 {
                if let Some(f) = random_file_from_dir(&inicio_dir) {
                    play_comm_audio(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await;
                }
            }
            comerciales_reproducidos += 1;
            info!("Stream comercial: {} ({}s)", url, commercial.duration_secs);
            play_comm_audio(&state, url, upvol, commercial.duration_secs, true, &mut None).await;
        } else {
            let stem = commercial.path.file_stem()
                .and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
            if stem == "hora" {
                remove_first_line_from_file(&comerciales_path_loop);
                // "Hora" cuenta como entrada real de tanda
                if comerciales_reproducidos == 0 {
                    if let Some(f) = random_file_from_dir(&inicio_dir) {
                        play_comm_audio(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await;
                    }
                }
                comerciales_reproducidos += 1;
                play_hora(&state).await;
            } else {
                let resolved = if commercial.path.is_dir() {
                    random_file_from_dir(&commercial.path)
                } else if commercial.path.exists() {
                    Some(commercial.path.clone())
                } else {
                    None
                };
                if let Some(file) = resolved {
                    remove_first_line_from_file(&comerciales_path_loop);
                    if comerciales_reproducidos == 0 {
                        if let Some(f) = random_file_from_dir(&inicio_dir) {
                            play_comm_audio(&state, &path_to_uri(&f), upvol, 0.0, false, &mut None).await;
                        }
                    }
                    comerciales_reproducidos += 1;
                    let uri = path_to_uri(&file);
                    let mut real_secs_c: Option<f64> = None;
                    play_comm_audio(&state, &uri, upvol, commercial.duration_secs, false, &mut real_secs_c).await;
                    log_comercial(&file, real_secs_c.unwrap_or(commercial.duration_secs));
                } else {
                    remove_first_line_from_file(&comerciales_path_loop);
                    warn!("Comercial no encontrado: {}", commercial.path.display());
                }
            }
        }
    }

    // ── Audio de fin: solo si al menos un comercial se reprodujo realmente ──
    if comerciales_reproducidos > 0 {
        if let Some(fin_file) = random_file_from_dir(&fin_dir) {
            let uri = path_to_uri(&fin_file);
            play_comm_audio(&state, &uri, upvol, 0.0, false, &mut None).await;
        }
    }

    // No vaciamos comercialeslist4 de golpe — los remove_first_line
    // del loop ya eliminaron cada línea al reproducirla.

    // ── Reproducir eventos en espera (sin jingles, sin fundido) ──
    let eventos_path = home.join(".gradio/data/tmp/eventos-esperalist");
    for evento in &eventos {
        if state.lock().unwrap().abort_pautaje { break; }
        if let Some(ref url) = evento.url {
            remove_first_line_from_file(&eventos_path);
            info!("Stream espera: {} ({}s)", url, evento.duration_secs);
            play_comm_audio(&state, url, upvol, evento.duration_secs, true, &mut None).await;
        } else {
            let stem = evento.path.file_stem()
                .and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
            if stem == "hora" {
                remove_first_line_from_file(&eventos_path);
                play_hora(&state).await;
            } else {
                let resolved = if evento.path.is_dir() {
                    random_file_from_dir(&evento.path)
                } else if evento.path.exists() {
                    Some(evento.path.clone())
                } else {
                    None
                };
                if let Some(file) = resolved {
                    remove_first_line_from_file(&eventos_path);
                    let uri = path_to_uri(&file);
                    let mut real_secs_e: Option<f64> = None;
                    play_comm_audio(&state, &uri, upvol, evento.duration_secs, false, &mut real_secs_e).await;
                    log_evento(&file, real_secs_e.unwrap_or(evento.duration_secs));
                } else {
                    remove_first_line_from_file(&eventos_path);
                    warn!("Evento en espera no encontrado: {}", evento.path.display());
                }
            }
        }
    }

    // No vaciamos eventos-esperalist de golpe — los remove_first_line
    // del loop ya eliminaron cada línea al reproducirla.

    // Asegurar pipeline_comm vacío
    let old = { state.lock().unwrap().pipeline_comm.take() };
    if let Some(p) = old { shutdown_pipeline(p); }

    // ── Liberar el bloqueo y dejar que start_next_track arranque el track ──
    // Resetear crossfade_triggered para que el timer opere normalmente
    {
        let mut st = state.lock().unwrap();
        if st.abort_pautaje {
            // El usuario interrumpió el pautaje arrastrando un audio al deck:
            // no arrancar el siguiente track, el deck ya está en marcha.
            st.abort_pautaje = false;
            st.commercials_playing = false;
            st.crossfade_triggered = false;
            return;
        }
        st.commercials_playing = false;
        st.crossfade_triggered = false;
    }
    start_next_track(state, track_index);
}

/// Espera hasta que el pipeline llegue a EOS, error, o se active abort_pautaje.
async fn wait_pipeline_eos(pipeline: &gst::Pipeline, state: &SharedState) {
    // Usar timed_pop(0) — no bloqueante — y ceder al main loop entre polls.
    // timed_pop con tiempo > 0 bloquea el hilo GTK y congela la UI.
    let bus = pipeline.bus().unwrap();
    loop {
        glib::timeout_future(Duration::from_millis(50)).await;
        // Interrupción por abort_pautaje (drag a deck durante comerciales)
        if state.lock().unwrap().abort_pautaje { return; }
        // Drena todos los mensajes pendientes sin bloquear
        loop {
            match bus.timed_pop(gst::ClockTime::ZERO) {
                None => break,
                Some(msg) => {
                    use gst::MessageView;
                    match msg.view() {
                        MessageView::Eos(_) => return,
                        MessageView::Error(e) => {
                            error!("Pipeline EOS-wait error: {}", e.error());
                            return;
                        }
                        _ => {}
                    }
                }
            }
        }
    }
}

// ─── Diálogo insertar streaming ─────────────────────────────────────────────

/// Muestra un diálogo flotante no modal con la ruta completa del track.
/// Click en la ruta la copia al portapapeles.
fn show_path_dialog(parent: &gtk4::ApplicationWindow, path: &str) {
    let dialog = gtk4::Window::new();
    dialog.set_title(Some(i18n::tr("dlg.ruta_archivo")));
    dialog.set_transient_for(Some(parent));
    dialog.set_modal(false);
    dialog.set_default_size(560, -1);
    dialog.set_resizable(false);

    let vbox = GtkBox::new(Orientation::Vertical, 8);
    vbox.set_margin_top(16);
    vbox.set_margin_bottom(16);
    vbox.set_margin_start(16);
    vbox.set_margin_end(16);

    let lbl = Label::new(Some(path));
    lbl.set_wrap(true);
    lbl.set_wrap_mode(gtk4::pango::WrapMode::WordChar);
    lbl.set_selectable(true);
    lbl.set_halign(gtk4::Align::Start);
    lbl.add_css_class("status-label");

    let btn_copy = Button::with_label(i18n::tr("dlg.copiar_ruta"));
    btn_copy.add_css_class("global-btn");
    let path_owned = path.to_string();
    let dialog_ref = dialog.clone();
    btn_copy.connect_clicked(move |_| {
        if let Some(display) = gdk4::Display::default() {
            display.clipboard().set_text(&path_owned);
        }
        dialog_ref.close();
    });

    let btn_close = Button::with_label(i18n::tr("btn.close"));
    btn_close.add_css_class("global-btn");
    let dialog_ref2 = dialog.clone();
    btn_close.connect_clicked(move |_| { dialog_ref2.close(); });

    let btn_row = GtkBox::new(Orientation::Horizontal, 8);
    btn_row.set_halign(gtk4::Align::End);
    btn_row.append(&btn_copy);
    btn_row.append(&btn_close);

    vbox.append(&lbl);
    vbox.append(&btn_row);
    dialog.set_child(Some(&vbox));
    dialog.present();
}

fn show_insert_stream_dialog(
    parent: &ApplicationWindow,
    state: SharedState,
    insert_idx: usize,
    playlist_path: PathBuf,
) {
    // Ventana modal simple con dos campos
    let dialog = gtk4::Window::new();
    dialog.set_title(Some(i18n::tr("dlg.ins_stream_title")));
    dialog.set_transient_for(Some(parent));
    dialog.set_modal(true);
    dialog.set_default_size(420, 180);
    dialog.set_resizable(false);

    let vbox = GtkBox::new(Orientation::Vertical, 12);
    vbox.set_margin_top(20);
    vbox.set_margin_bottom(20);
    vbox.set_margin_start(20);
    vbox.set_margin_end(20);

    // Campo URL
    let url_box = GtkBox::new(Orientation::Horizontal, 8);
    let url_lbl = Label::new(Some(i18n::tr("dlg.url_lbl")));
    url_lbl.set_width_chars(10);
    url_lbl.set_xalign(1.0);
    let url_entry = Entry::new();
    url_entry.set_hexpand(true);
    url_entry.set_placeholder_text(Some("https://stream.example.com/mi-radio"));
    url_box.append(&url_lbl);
    url_box.append(&url_entry);

    // Campo duración
    let dur_box = GtkBox::new(Orientation::Horizontal, 8);
    let dur_lbl = Label::new(Some(i18n::tr("dlg.duracion_s")));
    dur_lbl.set_width_chars(10);
    dur_lbl.set_xalign(1.0);
    let dur_entry = Entry::new();
    dur_entry.set_width_chars(8);
    dur_entry.set_placeholder_text(Some("60"));
    dur_entry.set_text("60");
    dur_box.append(&dur_lbl);
    dur_box.append(&dur_entry);

    // Botones
    let btn_box = GtkBox::new(Orientation::Horizontal, 8);
    btn_box.set_halign(gtk4::Align::End);

    let btn_cancel = Button::with_label(i18n::tr("btn.cancel"));
    let btn_insert = Button::with_label(i18n::tr("dlg.btn_insertar"));
    btn_insert.add_css_class("suggested-action");

    btn_box.append(&btn_cancel);
    btn_box.append(&btn_insert);

    vbox.append(&url_box);
    vbox.append(&dur_box);
    vbox.append(&btn_box);
    dialog.set_child(Some(&vbox));

    // Cancelar
    let dlg_cancel = dialog.clone();
    btn_cancel.connect_clicked(move |_| {
        dlg_cancel.close();
    });

    // Insertar
    let dlg_insert = dialog.clone();
    let url_e = url_entry.clone();
    let dur_e = dur_entry.clone();
    btn_insert.connect_clicked(move |_| {
        let url = url_e.text().to_string();
        let dur: f64 = dur_e.text().to_string().parse().unwrap_or(60.0);
        if url.is_empty() { return; }

        let short = url.split('/').last().unwrap_or(&url).to_string();
        let display = if short.is_empty() { url.clone() } else { short };
        let stream_track = Track {
            path: PathBuf::from(&url),
            duration_secs: dur,
            title: format!("📡 {}", display),
        };
        {
            let mut st = state.lock().unwrap();
            st.playlist.insert(insert_idx, stream_track);
            save_playlist_to_file(&st.playlist, &playlist_path);
            st.playlist_version = st.playlist_version.wrapping_add(1);
        }
        dlg_insert.close();
    });

    // Enter en url_entry también activa insertar
    let btn_insert_ref = btn_insert.clone();
    url_entry.connect_activate(move |_| { btn_insert_ref.activate(); });
    let btn_insert_ref2 = btn_insert.clone();
    dur_entry.connect_activate(move |_| { btn_insert_ref2.activate(); });

    dialog.present();
}

// ─── CSS ──────────────────────────────────────────────────────────────────────

const APP_CSS: &str = r#"
/* ── Fondo general: negro como GRadio ── */
window {
    background-color: #1c1c1c;
    color: #e0e0e0;
}

/* ── Cabecera barra superior ── */
.header-bar {
    background-color: #111111;
    border-bottom: 2px solid #3a3a3a;
    padding: 6px 12px;
    min-height: 56px;
}

.vu-row {
    background-color: #0d0d0d;
    padding: 2px 8px;
    border-bottom: 1px solid #222222;
}

.vu-ch-label {
    font-family: "monospace";
    font-size: 10px;
    color: #888888;
    min-width: 10px;
}

.vu-gradio-label {
    font-family: "sans-serif";
    font-size: 16px;
    font-weight: bold;
    color: #ff2222;
    padding-left: 12px;
}

.vu-pulso-label {
    font-family: "sans-serif";
    font-size: 11px;
    font-weight: bold;
    color: #9933cc;
    padding-left: 12px;
}

.vu-cliente-label {
    font-family: "sans-serif";
    font-size: 11px;
    color: #00cc88;
}

/* ── Reloj: verde brillante exacto de GRadio ── */
.clock-label {
    color: #00e676;
    font-family: "Liberation Mono", "DejaVu Sans Mono", monospace;
    font-size: 33px;
    font-weight: bold;
}

/* ── Barra de estado / nombre del tema en curso ── */
.now-playing-bar {
    background-color: #2a2a2a;
    border-bottom: 1px solid #444;
    padding: 3px 8px;
}

.now-playing-label {
    color: #00e676;
    font-family: "Liberation Mono", monospace;
    font-size: 18px;
    font-weight: bold;
}

/* ── Versión bajo el logo ── */
.version-lbl {
    color: #8899bb;
    font-size: 9px;
    font-style: italic;
}

/* ── Nombre de la radio (púrpura) ── */
.station-name-lbl {
    color: #cc88ff;
    font-size: 15px;
    font-weight: bold;
    margin-left: 6px;
    margin-right: 6px;
}

/* ── Info de volumen y fundido ── */
.status-label {
    color: #aaaaaa;
    font-size: 16px;
    font-style: italic;
}

/* ── Frames de deck A y B ── */
.deck-frame {
    background-color: #252525;
    border-radius: 4px;
    border: 1px solid #444444;
    padding: 2px;
}

.deck-frame > label {
    color: #cccccc;
    font-weight: bold;
    font-size: 12px;
    padding: 2px 6px;
    background-color: #333333;
}

/* ── Título del tema dentro del deck ── */
.track-title {
    color: #ffffff;
    font-size: 13px;
    font-weight: bold;
    margin-bottom: 2px;
    background-color: #1a6496;
    padding: 3px 6px;
    border-radius: 2px;
}

/* ── Tiempo transcurrido / total ── */
.time-label {
    color: #00e676;
    font-family: "Liberation Mono", monospace;
    font-size: 12px;
    font-weight: bold;
    min-width: 58px;
}

/* ── Barra de progreso (seek) ── */
scale trough {
    background-color: #3a3a3a;
    min-height: 8px;
    border-radius: 4px;
}

scale highlight {
    background-color: #1a6496;
    border-radius: 4px;
}

scale slider {
    background-color: #00aaff;
    min-width: 14px;
    min-height: 14px;
    border-radius: 7px;
    border: 2px solid #0077cc;
}

/* ── Botones de control (Play, Pausa, Stop, etc.) ── */
.control-btn {
    background-color: #e8edf3;
    color: #0d1b3e;
    border-radius: 6px;
    border: 2px solid #1a3a6b;
    padding: 4px 8px;
    font-size: 11px;
    font-weight: bold;
    min-width: 60px;
}

.control-btn:hover {
    background-color: #1a3a6b;
    color: #ffffff;
    border-color: #1a3a6b;
}

.control-btn:active {
    background-color: #0d1b3e;
    color: #ffffff;
    border-color: #0d1b3e;
}

/* ── Botones icono de deck (cuadrados, tamaño doble) ── */
button.deck-icon-btn,
button.deck-icon-btn * {
    all: unset;
}
button.deck-icon-btn {
    background: #555555;
    border-radius: 6px;
    border: 2px solid #777777;
    padding: 2px;
    min-width: 44px;
    min-height: 44px;
}

button.deck-icon-btn:hover {
    background: #6a6a6a;
    border-color: #aaaaaa;
}

button.deck-icon-btn:active {
    background: #3a3a3a;
}

/* Loop activo */
button.deck-icon-btn-active,
button.deck-icon-btn-active * {
    all: unset;
}
button.deck-icon-btn-active {
    background: #3d1212;
    border-radius: 6px;
    border: 2px solid #7a3030;
    padding: 2px;
    min-width: 44px;
    min-height: 44px;
}

button.deck-icon-btn-active:hover {
    background: #4d1818;
}

/* Botón "Detener al final" armado (pendiente de disparar) */
@keyframes stop-pending-pulse {
    0%   { background: #3d1212; border-color: #7a3030; }
    50%  { background: #7a1a1a; border-color: #ee4444; }
    100% { background: #3d1212; border-color: #7a3030; }
}
button.deck-icon-btn-stop,
button.deck-icon-btn-stop * {
    all: unset;
}
button.deck-icon-btn-stop {
    background: #3d1212;
    border-radius: 6px;
    border: 2px solid #7a3030;
    padding: 2px;
    min-width: 44px;
    min-height: 44px;
    animation: stop-pending-pulse 1s ease-in-out infinite;
}
button.deck-icon-btn-stop:hover {
    background: #5a1a1a;
}

/* ── Botones icono barra global (cuadrados medianos) ── */
button.global-icon-btn,
button.global-icon-btn * {
    all: unset;
}
button.global-icon-btn {
    background: #555555;
    border-radius: 5px;
    border: 2px solid #777777;
    padding: 2px;
    min-width: 36px;
    min-height: 36px;
}

button.global-icon-btn:hover {
    background: #6a6a6a;
    border-color: #aaaaaa;
}

button.global-icon-btn:active {
    background: #3a3a3a;
}

button.global-icon-btn:checked {
    background: #1a6a1a;
    border-color: #2aaa2a;
}

/* ── Botón Stop General (ícono rojo) ── */
button.stop-general-btn { background: #5a1a1a; border-color: #8a3333; }
button.stop-general-btn:hover  { background: #6a2222; }
button.stop-general-btn:active { background: #8a2222; }

/* ── Botones externos cuadrados con texto ── */
.ext-sq-btn {
    background-color: #1a2a3a;
    color: #4a90d9;
    border-radius: 5px;
    border: 2px solid #2a5a8a;
    padding: 2px 4px;
    font-size: 10px;
    font-weight: bold;
    min-width: 36px;
    min-height: 36px;
}

.ext-sq-btn:hover {
    background-color: #2a5a8a;
    color: #ffffff;
}

.ext-sq-btn:active {
    background-color: #0e1e2e;
    color: #ffffff;
}

/* ── Botón Duck / micrófono ── */
.duck-btn {
    background-color: #e8edf3;
    color: #0d1b3e;
    border-radius: 6px;
    border: 2px solid #1a3a6b;
    padding: 5px 12px;
    font-weight: bold;
    font-size: 12px;
}

.duck-btn:checked {
    background-color: #1a3a6b;
    color: #ffffff;
    border-color: #1a3a6b;
}

.duck-btn:hover {
    background-color: #1a3a6b;
    color: #ffffff;
    border-color: #1a3a6b;
}

/* ── Frames de las listas (cola playlist / comerciales) ── */
.list-frame {
    background-color: #1c1c1c;
    border-radius: 3px;
    border: 1px solid #444444;
}

.list-frame > label {
    color: #cccccc;
    font-weight: bold;
    font-size: 12px;
    background-color: #333333;
    padding: 2px 6px;
}

/* ── ListBox de cola ── */
.queue-list {
    background-color: #1c1c1c;
}

/* Fila normal */
.queue-list row {
    padding: 4px 8px;
    border-bottom: 1px solid #2e2e2e;
    color: #cccccc;
    font-size: 18px;
    background-color: #1c1c1c;
}

/* Fila par: ligeramente más clara, como GRadio */
.queue-list row:nth-child(even) {
    background-color: #242424;
}

.queue-list row:hover {
    background-color: #2a2a2a;
}

/* ── Menú contextual flotante ── */
.context-menu-box {
    background: #2a2a2a;
    border-radius: 6px;
    padding: 4px;
    border: 1px solid #555;
}
.context-menu-item {
    background: transparent;
    color: #e0e0e0;
    border: none;
    border-radius: 4px;
    padding: 6px 16px;
    font-size: 13px;
    min-width: 220px;
}
.context-menu-item:hover {
    background: #3d6fa5;
    color: white;
}
.context-menu-item-danger {
    color: #ff6b6b;
}
.context-menu-item-danger:hover {
    background: #8b1a1a;
    color: white;
}

/* Primera fila = próximo a reproducir: resaltado azul GRadio */
.playing-row {
    background-color: #1a6496;
    color: #ffffff;
    font-weight: bold;
}

/* Botón numérico de la cola de reproducción */
button.queue-idx-btn,
button.queue-idx-btn * {
    all: unset;
}
button.queue-idx-btn {
    background: #1a6496;
    color: #ffffff;
    font-size: 11px;
    font-weight: bold;
    min-width: 26px;
    min-height: 18px;
    padding: 2px 5px;
    border-radius: 3px;
}
button.queue-idx-btn:hover {
    background: #2980b9;
}
button.queue-idx-btn:active {
    background: #145074;
}

/* Botones de acción inline (subir / bajar / eliminar) en la cola */
button.queue-action-btn {
    background: transparent;
    border: none;
    border-radius: 3px;
    padding: 2px;
    min-width: 26px;
    min-height: 22px;
    opacity: 0.35;
}
button.queue-action-btn:hover {
    opacity: 1.0;
    background: rgba(100,100,100,0.35);
}
button.queue-action-btn:active {
    opacity: 1.0;
    background: rgba(100,100,100,0.55);
}

/* ── Duración por fila del playlist ── */
label.queue-dur-label {
    font-size: 11px;
    color: #8a9ab0;
    min-width: 42px;
    margin-right: 2px;
}

/* ── Duración total del playlist (cabecera) ── */
label.playlist-total-dur {
    font-size: 11px;
    color: #7ab0d4;
    font-weight: bold;
    margin-right: 6px;
}

/* ── Botones globales (Iniciar, Siguiente) ── */
.global-btn {
    background-color: #e8edf3;
    color: #0d1b3e;
    border-radius: 6px;
    border: 2px solid #1a3a6b;
    padding: 6px 16px;
    font-size: 12px;
    font-weight: bold;
    min-width: 130px;
}

.global-btn:hover {
    background-color: #1a3a6b;
    color: #ffffff;
    border-color: #1a3a6b;
}

.global-btn:active {
    background-color: #0d1b3e;
    color: #ffffff;
}

/* ── Botones de programas externos ── */
.ext-btn {
    background-color: #1a2a3a;
    color: #4a90d9;
    border-radius: 6px;
    border: 2px solid #2a5a8a;
    padding: 6px 12px;
    font-size: 11px;
    font-weight: bold;
}

.ext-btn:hover {
    background-color: #2a5a8a;
    color: #ffffff;
}

.ext-btn:active {
    background-color: #0e1e2e;
    color: #ffffff;
}

/* ── Botón Loop activo ── */
.loop-btn-on {
    background-color: #cc0000;
    border: 2px solid #ff4444;
    border-radius: 6px;
    padding: 6px 12px;
    font-size: 11px;
    font-weight: bold;
}
.config-label {
    color: #e0c040;
    font-weight: bold;
    font-size: 12px;
}

.loop-btn-on label {
    color: #ffffff;
}
.loop-btn-on:hover {
    background-color: #ff2222;
}

/* ── Cabecera del panel playlist ── */
.playlist-header {
    background-color: #333333;
    padding: 2px 6px;
    border-bottom: 1px solid #444444;
}
.playlist-header label {
    color: #cccccc;
    font-weight: bold;
    font-size: 12px;
}

/* ── Botón Automático (refill) — verde=activo, rojo=pausado ── */
button.refill-auto-btn,
button.refill-auto-btn * { all: unset; }
button.refill-auto-btn {
    background: #1a4a1a;
    border-radius: 5px;
    border: 2px solid #2a7a2a;
    padding: 2px;
    min-width: 28px;
    min-height: 28px;
}
button.refill-auto-btn:hover { background: #2a6a2a; border-color: #3aaa3a; }
button.refill-auto-btn:checked {
    background: #5a1a1a;
    border-color: #8a3333;
}
button.refill-auto-btn:checked:hover { background: #6a2222; border-color: #aa4444; }

/* ── Botón Vaciar playlist ── */
button.vaciar-playlist-btn,
button.vaciar-playlist-btn * { all: unset; }
button.vaciar-playlist-btn {
    background: #3a2a1a;
    border-radius: 5px;
    border: 2px solid #6a5a3a;
    padding: 2px;
    min-width: 28px;
    min-height: 28px;
}
button.vaciar-playlist-btn:hover { background: #5a3a1a; border-color: #9a7a3a; }
button.vaciar-playlist-btn:active { background: #6a2222; border-color: #aa4444; }

/* ── Tarjetas de tandas de comerciales próximas ── */
.tanda-card { border-radius: 6px; padding: 4px; margin: 2px; }
.tanda-header { font-weight: bold; font-size: 13px; }
/* background-image: image() es la única forma que el tema oscuro no anula */
box.tanda-bg-0 { background-image: image(#1e3a5c); color: #bbdefb; border-radius: 4px; }
box.tanda-bg-1 { background-image: image(#1b4332); color: #b7e4c7; border-radius: 4px; }
box.tanda-bg-2 { background-image: image(#7f4f24); color: #ffe8a3; border-radius: 4px; }
box.tanda-bg-3 { background-image: image(#3d1a5c); color: #e9d5ff; border-radius: 4px; }
/* Cola activa (comercialeslist4) — rojo oscuro para distinguir de las futuras */
box.tanda-bg-active { background-image: image(#5a1a1a); color: #ffcccc; border-radius: 4px; }
box.tanda-bg-0 button, box.tanda-bg-1 button,
box.tanda-bg-2 button, box.tanda-bg-3 button { min-width: 24px; min-height: 24px; padding: 0 4px; }

/* ── Botón procesador de audio activo ── */
button.processor-on { background-image: image(#1565c0); color: white; }

"#;

// ─── Estructura de carpetas G Radio (primer arranque) ────────────────────────

/// Crea la estructura de carpetas `~/G Radio/` y preconfigura `gradio.config`
/// con sus rutas si el archivo de configuración todavía no existe.
fn crear_dirs_g_radio(home: &std::path::Path) {
    let base = home.join("G Radio");
    let dirs = [
        "inicio-espacio-pub",
        "fin-espacio-pub",
        "pisadores",
        "Intercultural",
    ];
    for d in &dirs {
        let path = base.join(d);
        if let Err(e) = fs::create_dir_all(&path) {
            error!("crear_dirs_g_radio: no se pudo crear {}: {}", path.display(), e);
        }
    }
    info!("crear_dirs_g_radio: estructura ~/G Radio/ verificada");

    // Si gradio.config no existe aún, escribir uno con las rutas por defecto
    let cfg_path = config_path();
    if cfg_path.exists() {
        return;
    }
    if let Some(parent) = cfg_path.parent() {
        let _ = fs::create_dir_all(parent);
    }
    let pisadores  = base.join("pisadores");
    let intercult  = base.join("Intercultural");
    // Formato: una opción por línea (ver read_gradio_config)
    // L1: main_dev  L2: cue_dev  L3: station_name  L4: crossfade
    // L5: pisador_enabled  L6: pisador_dir  L7: pisador_every  L8: pisador_exclude
    // L9: silence_secs  L10: nacionales  L11: intercultural
    let config_content = format!(
        "\n\nG Radio\n3\n1\n{pisadores}\n5\n\n5.0\n\n\"{intercultural}\"\n",
        pisadores   = pisadores.display(),
        intercultural = intercult.display(),
    );
    match fs::write(&cfg_path, &config_content) {
        Ok(_)  => info!("crear_dirs_g_radio: gradio.config inicial escrito en {}", cfg_path.display()),
        Err(e) => error!("crear_dirs_g_radio: no se pudo escribir gradio.config: {}", e),
    }
}

// ─── Instalación inicial de archivos de hora ─────────────────────────────────

/// Copia los archivos de hora (HRS*.mp3, MIN*.mp3) desde la carpeta `data/Time/`
/// junto al ejecutable hacia `~/.gradio/data/panel/Time/`.
/// Solo actúa si el destino está vacío o no existe.
fn install_time_files_if_needed(home: &std::path::Path) {
    let dest = home.join(".gradio/data/panel/Time");
    if dest.join("HRS00.mp3").exists() {
        return;
    }
    let exe_dir = std::env::current_exe()
        .ok()
        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
        .unwrap_or_default();
    let src = exe_dir.join("data").join("Time");
    if !src.is_dir() {
        warn!("install_time_files: carpeta fuente no encontrada en {}", src.display());
        return;
    }
    if let Err(e) = fs::create_dir_all(&dest) {
        error!("install_time_files: no se pudo crear {}: {}", dest.display(), e);
        return;
    }
    let entries = match fs::read_dir(&src) {
        Ok(e) => e,
        Err(e) => { error!("install_time_files: error leyendo {}: {}", src.display(), e); return; }
    };
    let mut count = 0u32;
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) == Some("mp3") {
            let dst = dest.join(entry.file_name());
            if !dst.exists() {
                if let Err(e) = fs::copy(&path, &dst) {
                    error!("install_time_files: error copiando {}: {}", path.display(), e);
                } else {
                    count += 1;
                }
            }
        }
    }
    if count > 0 {
        info!("install_time_files: {} archivos instalados en {}", count, dest.display());
    }
}

// ─── Función principal ────────────────────────────────────────────────────────

fn main() {
    env_logger::init();

    gst::init().expect("No se pudo inicializar GStreamer");

    // Iniciar servidor TCP para gr-client remoto
    let gr_cfg_srv = read_gradio_config();

    // i18n: forzado por config o autodetectado del entorno
    let lc = i18n::init(gr_cfg_srv.locale.as_deref());
    eprintln!("[radio-player] locale = {}", lc.code());

    let cliente_conectado: servidor::ClienteConectado = Arc::new(Mutex::new(String::new()));
    servidor::iniciar(gr_cfg_srv.client_port, gr_cfg_srv.client_token.clone(), cliente_conectado.clone());

    // Iniciar relay internet si está habilitado
    if gr_cfg_srv.relay_habilitado && gr_cfg_srv.relay_id.len() == 8
        && gr_cfg_srv.relay_id.chars().all(|c| c.is_ascii_digit())
    {
        relay::iniciar(gr_cfg_srv.relay_id, gr_cfg_srv.client_port, cliente_conectado.clone());
    }

    let app = Application::builder()
        .application_id("com.gradio.radio-player")
        .build();

    app.connect_activate(move |app| {
        build_ui(app, cliente_conectado.clone());
    });

    app.run();
}

fn build_ui(app: &Application, cliente_conectado: servidor::ClienteConectado) {
    let home = home_dir();

    crear_dirs_g_radio(&home);
    install_time_files_if_needed(&home);
    skin::crear_dir_skins_si_falta();

    // Instalar ícono en hicolor para que el menú y taskbar lo muestren correctamente.
    // Se instala en 48x48 (requerido por Cinnamon/GNOME para el menú) y 256x256.
    {
        let hicolor = home.join(".local/share/icons/hicolor");
        let sizes = ["48x48", "256x256"];
        let mut installed = false;
        for size in sizes {
            let icon_path = hicolor.join(size).join("apps").join("gradio-player.png");
            if !icon_path.exists() {
                if let Some(parent) = icon_path.parent() {
                    let _ = fs::create_dir_all(parent);
                }
                if fs::write(&icon_path, icon_gradio()).is_ok() {
                    installed = true;
                }
            }
        }
        if installed {
            let _ = std::process::Command::new("gtk-update-icon-cache")
                .args(["-f", "-t", &hicolor.to_string_lossy().into_owned()])
                .output();
        }
    }

    // ── Cargar configuración inicial ──
    let upvol_path    = home.join(".gradio/data/tmp/upvol");
    let downvol_path  = home.join(".gradio/data/tmp/downvol");
    let playlist_path = home.join(".gradio/data/tmp/playlist4");

    // Crear archivos de volumen con valores por defecto si no existen
    if !upvol_path.exists() {
        let _ = fs::write(&upvol_path, "90");
    }
    if !downvol_path.exists() {
        let _ = fs::write(&downvol_path, "20");
    }

    let upvol_raw    = read_f64_from_file(&upvol_path).clamp(0.0, 100.0) / 100.0;
    let downvol_raw  = read_f64_from_file(&downvol_path).clamp(0.0, 100.0) / 100.0;
    let gr_cfg       = read_gradio_config();
    let crossfade_secs = gr_cfg.crossfade_secs;

    let playlist = load_playlist(&playlist_path);
    info!("Playlist cargada: {} temas", playlist.len());

    // ── Estado compartido ──
    let state = Arc::new(Mutex::new({
        let mut s = AppState::new();
        s.playlist = playlist;
        s.upvol = upvol_raw;
        s.downvol = downvol_raw;
        s.crossfade_secs = crossfade_secs;
        s.station_name = gr_cfg.station_name.clone();
        {
            // Crear el Arc único en arranque y registrarlo en el global.
            // Todos los pipelines comparten este mismo Arc para siempre.
            let proc_cfg = processor::ProcessorConfig::cargar();
            let proc_dsp = processor::nuevo_shared_dsp(proc_cfg.clone());
            *proc_dsp_global().lock().unwrap() = Some(proc_dsp.clone());
            PROC_ENABLED.store(proc_cfg.enabled, std::sync::atomic::Ordering::Relaxed);
            if proc_cfg.enabled {
                s.dsp = Some(proc_dsp); // UI: "encendido"
            }
            // Si disabled: PROC_DSP_GLOBAL tiene el Arc pero AppState.dsp = None (UI "apagado")
        }
        s
    }));

    // ── Timer: escribir estado.json para gr-client cada 2 segundos ──
    {
        let state_json = state.clone();
        let home_json  = home.clone();
        timeout_add_local(std::time::Duration::from_secs(2), move || {
            let st = state_json.lock().unwrap();
            let track_actual = st.current_track_path.to_string_lossy().to_string();
            let titulo_actual = Path::new(&track_actual)
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            let upvol = (st.upvol * 100.0).round() as u8;
            let downvol = (st.downvol * 100.0).round() as u8;
            let reproduciendo = matches!(st.deck_a_state, PlaybackState::Playing)
                || matches!(st.deck_b_state, PlaybackState::Playing);
            let pausado = matches!(st.deck_a_state, PlaybackState::Paused)
                || matches!(st.deck_b_state, PlaybackState::Paused);
            let comerciales_activos = st.commercials_playing;
            let eventos_activos = st.eventos_playing;
            let indice_actual = st.current_index;
            drop(st);

            let tmp = home_json.join(".gradio/data/tmp");
            let num_comerciales = contar_lineas_archivo(&tmp.join("comercialeslist4")) as i32;
            let num_eventos     = contar_lineas_archivo(&tmp.join("eventoslist")) as i32;
            let num_eventos_espera = contar_lineas_archivo(&tmp.join("eventos-esperalist")) as i32;

            let estado = servidor::EstadoRemoto {
                reproduciendo,
                pausado,
                track_actual,
                titulo_actual,
                posicion_secs: 0.0,
                duracion_secs: 0.0,
                upvol,
                downvol,
                comerciales_activos,
                eventos_activos,
                num_comerciales,
                num_eventos,
                num_eventos_espera,
                indice_actual,
            };
            servidor::escribir_estado_json(&estado);
            glib::ControlFlow::Continue
        });
    }

    // ── Ventana ──
    let window = ApplicationWindow::builder()
        .application(app)
        .title(i18n::tr("win.player"))
        .default_width(900)
        .default_height(520)
        .build();

    // Aplicar CSS
    let provider = CssProvider::new();
    provider.load_from_data(APP_CSS);
    gtk4::style_context_add_provider_for_display(
        &gtk4::prelude::WidgetExt::display(&window),
        &provider,
        STYLE_PROVIDER_PRIORITY_APPLICATION + 200,
    );
    skin::aplicar_css_extra(&gtk4::prelude::WidgetExt::display(&window));

    // ── Layout principal ──
    let root = GtkBox::new(Orientation::Vertical, 8);
    root.set_margin_top(10);
    root.set_margin_bottom(10);
    root.set_margin_start(10);
    root.set_margin_end(10);

    // ── Cabecera: reloj + estado ──
    let header = GtkBox::new(Orientation::Horizontal, 12);
    header.add_css_class("header-bar");
    header.set_margin_top(0);
    header.set_margin_bottom(0);
    let clock_label = Label::new(Some("00:00:00"));
    clock_label.add_css_class("clock-label");
    clock_label.set_margin_start(8);
    // Barra de progreso global (nombre del tema en reproducción)
    let now_playing = Label::new(Some("G Radio Player"));
    now_playing.add_css_class("now-playing-label");
    now_playing.set_hexpand(true);
    now_playing.set_halign(gtk4::Align::Center);
    now_playing.set_ellipsize(gtk4::pango::EllipsizeMode::End);
    let lbl_station = Label::new(Some(gr_cfg.station_name.as_str()));
    lbl_station.add_css_class("station-name-lbl");
    lbl_station.set_halign(gtk4::Align::End);
    let status_label = Label::new(Some(""));
    status_label.add_css_class("status-label");
    status_label.set_margin_end(8);

    // Logo G Radio a la derecha del nombre de la radio
    let logo_gradio = {
        let loader = gdk4::gdk_pixbuf::PixbufLoader::new();
        loader.write(&icon_gradio()).unwrap_or(());
        loader.close().unwrap_or(());
        if let Some(pb) = loader.pixbuf() {
            if let Some(sc) = pb.scale_simple(48, 48, gdk4::gdk_pixbuf::InterpType::Bilinear) {
                let tex = gdk4::Texture::for_pixbuf(&sc);
                let img = gtk4::Image::from_paintable(Some(&tex));
                img.set_size_request(48, 48);  // forzar tamaño de renderizado
                img.set_margin_end(8);
                img
            } else {
                let img = gtk4::Image::new();
                img.set_size_request(48, 48);
                img
            }
        } else {
            let img = gtk4::Image::new();
            img.set_size_request(48, 48);
            img
        }
    };

    // Shared state para el VU
    let vu_level_l_draw = std::rc::Rc::new(std::cell::Cell::new(0.0f64));
    let vu_level_r_draw = std::rc::Rc::new(std::cell::Cell::new(0.0f64));
    let vu_active_draw  = std::rc::Rc::new(std::cell::Cell::new(true));
    // Logo + versión (caja vertical a la derecha del header)
    let logo_box = GtkBox::new(Orientation::Vertical, 1);
    logo_box.set_halign(gtk4::Align::Center);
    logo_box.set_valign(gtk4::Align::Center);
    let lbl_version = Label::new(Some(concat!("G Radio v-", env!("CARGO_PKG_VERSION"))));
    lbl_version.add_css_class("version-lbl");
    logo_box.append(&logo_gradio);
    logo_box.append(&lbl_version);

    header.append(&clock_label);
    header.append(&now_playing);
    header.append(&lbl_station);
    header.append(&status_label);
    header.append(&logo_box);
    root.append(&header);

    // ── VU Meter — LEDs Cairo bajo el header ─────────────────────────────────
    const VU_LEDS: i32 = 24;
    const VU_LED_W: f64 = 15.0;
    const VU_LED_H: f64 = 8.0;
    const VU_LED_GAP: f64 = 2.0;

    // Función que dibuja una barra de LEDs dado un nivel 0.0-1.0
    fn draw_vu_leds(cr: &cairo::Context, _width: i32, height: i32, level: f64) {
        let n = VU_LEDS;
        let start_x = 0.0_f64;
        let y = ((height as f64) - VU_LED_H) / 2.0;
        let lit = (level * n as f64).round() as i32;
        for i in 0..n {
            let x = start_x + i as f64 * (VU_LED_W + VU_LED_GAP);
            let on = i < lit;
            // Verde 0-55%, amarillo 55-75%, rojo 75-100%
            let (r, g, b) = if i >= (n as f64 * 0.75) as i32 {
                if on { (1.0, 0.07, 0.0) } else { (0.25, 0.02, 0.0) }
            } else if i >= (n as f64 * 0.55) as i32 {
                if on { (0.9, 0.78, 0.0) } else { (0.22, 0.19, 0.0) }
            } else {
                if on { (0.0, 0.87, 0.13) } else { (0.0, 0.17, 0.03) }
            };
            cr.set_source_rgb(r, g, b);
            // LED con esquinas redondeadas (radio 1.5)
            let rad = 1.5_f64;
            cr.new_sub_path();
            cr.arc(x + VU_LED_W - rad, y + rad,           rad, -std::f64::consts::PI/2.0, 0.0);
            cr.arc(x + VU_LED_W - rad, y + VU_LED_H - rad, rad, 0.0,                      std::f64::consts::PI/2.0);
            cr.arc(x + rad,            y + VU_LED_H - rad, rad, std::f64::consts::PI/2.0, std::f64::consts::PI);
            cr.arc(x + rad,            y + rad,            rad, std::f64::consts::PI,     3.0*std::f64::consts::PI/2.0);
            cr.close_path();
            let _ = cr.fill();
        }
    }

    let vu_bar_l = DrawingArea::new();
    let vu_bar_r = DrawingArea::new();
    vu_bar_l.set_hexpand(true);
    vu_bar_r.set_hexpand(true);
    vu_bar_l.set_content_height(14);
    vu_bar_r.set_content_height(14);

    {
        let lev = vu_level_l_draw.clone();
        vu_bar_l.set_draw_func(move |_da, cr, w, h| {
            draw_vu_leds(cr, w, h, lev.get());
        });
    }
    {
        let lev = vu_level_r_draw.clone();
        vu_bar_r.set_draw_func(move |_da, cr, w, h| {
            draw_vu_leds(cr, w, h, lev.get());
        });
    }

    // Etiquetas "L" / "R"
    let lbl_l = Label::new(Some("L"));
    let lbl_r = Label::new(Some("R"));
    lbl_l.add_css_class("vu-ch-label");
    lbl_r.add_css_class("vu-ch-label");

    let row_l = GtkBox::new(Orientation::Horizontal, 2);
    row_l.append(&lbl_l);
    row_l.append(&vu_bar_l);

    let row_r = GtkBox::new(Orientation::Horizontal, 2);
    row_r.append(&lbl_r);
    row_r.append(&vu_bar_r);

    // Branding: "G-Radio player" (rojo) + "Pulso" (violeta, más pequeño)
    let gradio_label = Label::new(Some("G-Radio player"));
    gradio_label.add_css_class("vu-gradio-label");
    gradio_label.set_halign(gtk4::Align::End);

    let pulso_label = Label::new(Some("Pulso"));
    pulso_label.add_css_class("vu-pulso-label");
    pulso_label.set_halign(gtk4::Align::End);

    let brand_box = GtkBox::new(Orientation::Vertical, 0);
    brand_box.set_valign(gtk4::Align::Center);
    brand_box.set_halign(gtk4::Align::End);
    brand_box.append(&gradio_label);
    brand_box.append(&pulso_label);

    let vu_bars = GtkBox::new(Orientation::Vertical, 0);
    vu_bars.set_hexpand(true);
    vu_bars.append(&row_l);
    vu_bars.append(&row_r);

    let lbl_cliente = Label::new(None);
    lbl_cliente.add_css_class("vu-cliente-label");
    lbl_cliente.set_halign(gtk4::Align::Center);
    lbl_cliente.set_hexpand(true);

    let vu_row = GtkBox::new(Orientation::Horizontal, 8);
    vu_row.add_css_class("vu-row");
    vu_row.append(&vu_bars);
    vu_row.append(&lbl_cliente);
    vu_row.append(&brand_box);
    root.append(&vu_row);

    // ── Players: cuántos paneles de reproducción se muestran (3/2/1) ──
    // Requiere reinicio para aplicarse (se lee una sola vez acá). En modo 3
    // el comportamiento es exactamente el de siempre: ambos decks fijos y
    // visibles. En modo 2/1, ambos decks se siguen construyendo (más simple
    // y sin tocar el resto del tick) pero solo el que sea `active_deck` en
    // cada momento queda visible; sus botones resuelven el deck en el
    // instante del click (`None` = dinámico) para que "sigan" al deck que
    // tenga el control aunque el tick de visibilidad tarde hasta 200ms en
    // reflejarlo.
    let players_mode = gr_cfg.players;
    let deck_a_param = if players_mode == 3 { Some(ActiveDeck::A) } else { None };
    let deck_b_param = if players_mode == 3 { Some(ActiveDeck::B) } else { None };

    // ── Deck A ──
    let title_a = Label::new(Some(i18n::tr("now.no_track")));
    let time_a = Label::new(Some("00:00"));
    let total_a = Label::new(Some("00:00"));
    let adj_a = Adjustment::new(0.0, 0.0, 100.0, 1.0, 10.0, 0.0);
    let seek_a = Scale::new(Orientation::Horizontal, Some(&adj_a));
    seek_a.set_draw_value(false);

    let (deck_a_frame, btn_stop_end_a) = build_deck_frame(
        i18n::tr("deck.a"),
        state.clone(),
        deck_a_param,
        &title_a,
        &time_a,
        &total_a,
        &seek_a,
    );

    // ── Deck B ──
    let title_b = Label::new(Some(i18n::tr("now.no_track")));
    let time_b = Label::new(Some("00:00"));
    let total_b = Label::new(Some("00:00"));
    let adj_b = Adjustment::new(0.0, 0.0, 100.0, 1.0, 10.0, 0.0);
    let seek_b = Scale::new(Orientation::Horizontal, Some(&adj_b));
    seek_b.set_draw_value(false);

    let (deck_b_frame, btn_stop_end_b) = build_deck_frame(
        i18n::tr("deck.b"),
        state.clone(),
        deck_b_param,
        &title_b,
        &time_b,
        &total_b,
        &seek_b,
    );

    let decks_row = GtkBox::new(Orientation::Horizontal, 8);
    deck_a_frame.set_hexpand(true);
    deck_b_frame.set_hexpand(true);
    decks_row.append(&deck_a_frame);
    decks_row.append(&deck_b_frame);
    root.append(&decks_row);
    if players_mode != 3 {
        // Estado inicial: al arrancar, active_deck es A y no hay comercial.
        deck_a_frame.set_visible(true);
        deck_b_frame.set_visible(false);
    }

    // ── Barra de comerciales en curso ──
    let comm_bar = GtkBox::new(Orientation::Horizontal, 8);
    comm_bar.set_margin_top(2);
    comm_bar.set_margin_bottom(2);
    let comm_icon = Label::new(Some("📢"));
    let comm_title = Label::new(Some(i18n::tr("now.no_ad")));
    comm_title.add_css_class("track-title");
    comm_title.set_hexpand(true);
    comm_title.set_ellipsize(gtk4::pango::EllipsizeMode::End);
    let comm_time = Label::new(Some("00:00"));
    comm_time.add_css_class("time-label");
    let comm_total = Label::new(Some("00:00"));
    comm_total.add_css_class("time-label");
    let comm_adj = Adjustment::new(0.0, 0.0, 100.0, 1.0, 10.0, 0.0);
    let comm_seek = Scale::new(Orientation::Horizontal, Some(&comm_adj));
    comm_seek.set_draw_value(false);
    comm_seek.set_hexpand(true);
    comm_bar.append(&comm_icon);
    comm_bar.append(&comm_title);
    comm_bar.append(&comm_time);
    comm_bar.append(&comm_seek);
    comm_bar.append(&comm_total);
    root.append(&comm_bar);
    if players_mode == 1 {
        // Modo 1: un solo panel visible — arranca mostrando el deck activo.
        comm_bar.set_visible(false);
    }

    // ── Barra de controles + botones externos + vol info ──
    let global_box = GtkBox::new(Orientation::Horizontal, 6);
    global_box.set_margin_top(4);
    global_box.set_margin_bottom(2);

    // Botones principales de playlist
    let btn_start = icon_button(&icon_gr_off(), i18n::tr("tool.start"), 36);
    btn_start.add_css_class("global-icon-btn");
    let btn_stop_general = icon_button(&icon_stop(), i18n::tr("tool.stop_general"), 36);
    btn_stop_general.add_css_class("global-icon-btn");
    btn_stop_general.add_css_class("stop-general-btn");
    let btn_next = icon_button(&icon_siguiente(), i18n::tr("tool.next"), 36);
    btn_next.add_css_class("global-icon-btn");

    // Botón Hora
    let btn_hora = icon_button(&icon_hora_off(), i18n::tr("tool.hora"), 36);
    btn_hora.add_css_class("global-icon-btn");

    // Botón Pisador
    let btn_pisador = icon_button(&icon_pisador(), i18n::tr("tool.pisador"), 36);
    btn_pisador.add_css_class("global-icon-btn");

    // Duck toggle (movido aquí desde arriba)
    let duck_btn = icon_toggle_button(&icon_fadeout_off(), i18n::tr("tool.duck"), 36);
    duck_btn.add_css_class("global-icon-btn");
    let btn_buscador = icon_button(&icon_busqueda(), i18n::tr("tool.buscador"), 36);
    btn_buscador.add_css_class("global-icon-btn");

    // Botones de programas externos
    let btn_pautaje = icon_button(&icon_pautaje(), i18n::tr("tool.pautaje"), 36);
    btn_pautaje.add_css_class("global-icon-btn");
    let btn_parrilla = icon_button(&icon_parrilla(), i18n::tr("tool.parrilla"), 36);
    btn_parrilla.add_css_class("global-icon-btn");
    let btn_playlist = icon_button(&icon_playlist48(), i18n::tr("tool.playlist"), 36);
    btn_playlist.add_css_class("global-icon-btn");
    let btn_botonera = icon_button(&icon_botonera(), i18n::tr("tool.botonera"), 36);
    btn_botonera.add_css_class("global-icon-btn");
    let btn_config = icon_button(&icon_config(), i18n::tr("tool.config"), 36);
    btn_config.add_css_class("global-icon-btn");
    let btn_visor = icon_button(&icon_visor(), i18n::tr("tool.visor"), 36);
    btn_visor.add_css_class("global-icon-btn");
    let btn_record = icon_button(&icon_grabar(), i18n::tr("tool.record"), 36);
    btn_record.add_css_class("global-icon-btn");
    let btn_operador = Button::with_label(i18n::tr("btn.operador"));
    btn_operador.add_css_class("ext-btn");
    let btn_reportes = icon_button(&icon_reportes(), i18n::tr("tool.reportes"), 36);
    btn_reportes.add_css_class("global-icon-btn");

    // VU Meter — cargar SVG como PNG vía pixbuf
    let btn_vumeter = {
        let btn = Button::new();
        btn.set_tooltip_text(Some(i18n::tr("tip.vu_meter")));
        btn.set_size_request(36, 36);
        btn.add_css_class("global-icon-btn");
        // Intentar cargar el SVG; si falla usar texto
        let svg_bytes = include_bytes!("../assets/VUmetro.svg");
        let loader = gdk4::gdk_pixbuf::PixbufLoader::with_type("svg").unwrap_or_else(|_|
            gdk4::gdk_pixbuf::PixbufLoader::new()
        );
        let _ = loader.set_size(26, 26);
        loader.write(svg_bytes).unwrap_or(());
        loader.close().unwrap_or(());
        if let Some(pb) = loader.pixbuf() {
            let tex = gdk4::Texture::for_pixbuf(&pb);
            let img = gtk4::Image::from_paintable(Some(&tex));
            btn.set_child(Some(&img));
        } else {
            btn.set_label("VU");
        }
        btn
    };

    // Etiqueta de volumen (al final de la barra)
    let vol_info = Label::new(Some(&i18n::tr("vol.fmt")
        .replace("{up}", &format!("{:.0}", upvol_raw * 100.0))
        .replace("{dn}", &format!("{:.0}", downvol_raw * 100.0))
        .replace("{mix}", &format!("{:.1}", crossfade_secs))));
    vol_info.add_css_class("status-label");
    vol_info.set_hexpand(false);
    vol_info.set_halign(gtk4::Align::Start);

    // Señal Stop General
    {
        let s = state.clone();
        btn_stop_general.connect_clicked(move |_| {
            interrupt_pautaje(&s);
            let mut st = s.lock().unwrap();
            action_stop(&mut st, ActiveDeck::A);
            action_stop(&mut st, ActiveDeck::B);
            info!("Stop General activado desde UI");
        });
    }

    // Grupo izquierdo: Iniciar, Stop General, Siguiente, Hora, Pisador, Bajar música, Buscador, Playlist
    let left_group = GtkBox::new(Orientation::Horizontal, 4);
    left_group.append(&btn_start);
    left_group.append(&btn_stop_general);
    left_group.append(&btn_next);
    left_group.append(&btn_hora);
    left_group.append(&btn_pisador);
    left_group.append(&duck_btn);
    left_group.append(&btn_buscador);
    left_group.append(&btn_playlist);

    // Separador expansible
    let sep = Label::new(None);
    sep.set_hexpand(true);

    // Botón toggle procesador — icono cambia según estado activo/inactivo
    let proc_on_init = PROC_ENABLED.load(std::sync::atomic::Ordering::Relaxed);
    let proc_icon_init = if proc_on_init { icon_proc_on() } else { icon_proc_off() };
    let btn_processor = icon_button(
        &proc_icon_init,
        if proc_on_init { i18n::tr("tip.dsp_on") }
        else            { i18n::tr("tip.dsp_off") },
        40,
    );
    if proc_on_init { btn_processor.add_css_class("processor-on"); }
    {
        let s = state.clone();
        btn_processor.connect_clicked(move |btn| {
            let mut st = s.lock().unwrap();
            // PROC_ENABLED es el control autoritativo: toggle atómico sin depender de mutex.
            let turning_on = !PROC_ENABLED.load(std::sync::atomic::Ordering::Relaxed);
            PROC_ENABLED.store(turning_on, std::sync::atomic::Ordering::Relaxed);
            // Persistir en disco (best-effort: si try_lock falla no es crítico,
            // PROC_ENABLED ya controla el procesamiento en tiempo real).
            if let Ok(g) = proc_dsp_global().try_lock() {
                if let Some(ref dsp) = *g {
                    if let Ok(mut d) = dsp.try_lock() {
                        d.cfg.enabled = turning_on;
                        d.cfg.guardar();
                    }
                }
            }
            if turning_on {
                // Actualizar st.dsp para consistencia con apply() — best-effort.
                if let Ok(g) = proc_dsp_global().try_lock() {
                    st.dsp = (*g).clone();
                }
                btn.add_css_class("processor-on");
                btn.set_tooltip_text(Some(i18n::tr("tip.dsp_on")));
                set_button_icon(btn, &icon_proc_on(), 40);
            } else {
                st.dsp = None;
                btn.remove_css_class("processor-on");
                btn.set_tooltip_text(Some(i18n::tr("tip.dsp_off")));
                set_button_icon(btn, &icon_proc_off(), 40);
            }
        });
    }

    // Botón configuración del procesador DSP
    let btn_dsp_cfg = icon_button(&icon_proc_cfg(), "Configurar procesador DSP", 40);
    {
        let win_c   = window.clone();
        let state_c = state.clone();
        btn_dsp_cfg.connect_clicked(move |_| {
            abrir_ventana_procesador(&win_c, state_c.clone());
        });
    }

    // Botón IA — visible solo cuando ia_habilitada está activo en la configuración
    let btn_ia = {
        let b = Button::new();
        let lbl = gtk4::Label::new(Some(i18n::tr("btn.ia")));
        b.set_child(Some(&lbl));
        b.set_size_request(36, 36);
        b.set_hexpand(false);
        b.set_vexpand(false);
        b.add_css_class("global-icon-btn");
        b.set_tooltip_text(Some(i18n::tr("tip.ia_btn")));
        let ia_on = read_gradio_config().ia_habilitada;
        b.set_visible(ia_on);
        b
    };
    {
        let home_ia = home.clone();
        let win_ia  = window.clone();
        btn_ia.connect_clicked(move |_| {
            let opencode_dir = home_ia.join(".gradio/data/opencode");
            // Verificar que existe opencode (primero en rutas conocidas, luego PATH)
            let opencode_bin = {
                let local = home_ia.join(".local/bin/opencode");
                let ocode = home_ia.join(".opencode/bin/opencode");
                if local.exists()       { Some(local) }
                else if ocode.exists()  { Some(ocode) }
                else if std::process::Command::new("which").arg("opencode")
                           .output().map(|o| o.status.success()).unwrap_or(false) {
                    Some(std::path::PathBuf::from("opencode"))
                } else { None }
            };
            if opencode_bin.is_none() {
                show_info_dialog(win_ia.upcast_ref::<gtk4::Window>(), i18n::tr("dlg.ia_warn_title"), i18n::tr("dlg.ia_no_bin"));
                return;
            }
            let bin = opencode_bin.unwrap();
            // Asegurar que existe el directorio de contexto
            let _ = fs::create_dir_all(&opencode_dir);
            // Intentar lanzar opencode-terminal; si no está o falla (ej. glibc),
            // usar fallback con terminal del sistema pasando el project dir como argumento.
            match std::process::Command::new("opencode-terminal")
                .env("GRADIO_OPENCODE_DIR", &opencode_dir)
                .spawn()
            {
                Ok(mut child) => {
                    // El proceso arrancó, pero puede morir inmediatamente por incompatibilidad
                    // (ej. glibc mismatch en Bookworm). Verificar tras 500ms.
                    let bin_t = bin.clone();
                    let dir_t = opencode_dir.clone();
                    std::thread::spawn(move || {
                        std::thread::sleep(std::time::Duration::from_millis(500));
                        if let Ok(Some(status)) = child.try_wait() {
                            if !status.success() {
                                launch_opencode_en_terminal(&bin_t, &dir_t);
                            }
                        }
                    });
                }
                Err(_) => {
                    // opencode-terminal no encontrado en PATH
                    launch_opencode_en_terminal(&bin, &opencode_dir);
                }
            }
        });
    }

    // Grupo derecho: IA, Vol info, Botonera, Pautaje, Parrilla, Config, Visor, Grabar, VU Meter, Reportes, Procesador, Cfg DSP
    let right_group = GtkBox::new(Orientation::Horizontal, 4);
    right_group.set_halign(gtk4::Align::End);
    right_group.append(&vol_info);
    right_group.append(&btn_botonera);
    right_group.append(&btn_pautaje);
    right_group.append(&btn_parrilla);
    right_group.append(&btn_config);
    right_group.append(&btn_visor);
    right_group.append(&btn_record);
    right_group.append(&btn_vumeter);
    right_group.append(&btn_reportes);
    right_group.append(&btn_processor);
    right_group.append(&btn_dsp_cfg);
    right_group.append(&btn_ia);

    global_box.append(&left_group);
    global_box.append(&sep);
    global_box.append(&right_group);
    root.append(&global_box);

    // ── Paneles de lista: Playlist (izq) | Comerciales (der) ──
    let lists_row = GtkBox::new(Orientation::Horizontal, 8);
    lists_row.set_vexpand(true);

    // Panel izquierdo: Playlist musical interactiva
    let playlist_frame = Frame::new(None::<&str>);
    playlist_frame.add_css_class("list-frame");
    playlist_frame.set_hexpand(true);
    playlist_frame.set_vexpand(true);
    let playlist_listbox = ListBox::new();
    playlist_listbox.add_css_class("queue-list");
    playlist_listbox.set_selection_mode(gtk4::SelectionMode::Single);
    // Doble clic activa la fila (no clic simple), para no interferir con tooltips
    playlist_listbox.set_activate_on_single_click(false);

    // El ScrolledWindow se crea aquí (antes que los DropTargets) para poder
    // instalarles también drop sobre el área visible más allá de la última fila.
    let playlist_scroll = ScrolledWindow::new();
    playlist_scroll.set_vexpand(true);
    playlist_scroll.set_min_content_height(80);
    playlist_scroll.set_child(Some(&playlist_listbox));

    // ── DropTarget unificado: reordenamiento interno + archivos externos ──
    // Acepta STRING (arrastre interno playlist-row:N) y
    // gdk4::FileList (text/uri-list de gestores de archivos como Nautilus/Thunar).
    // Se instalan también sobre el ScrolledWindow para cubrir el área vacía
    // bajo la última fila (cuando la ventana es alta o hay pocas filas).
    {
        let s = state.clone();
        let home_pl = home.clone();

        // (insert_paths_validated vive a nivel de módulo — valida con symphonia)

        // ── DropTarget único: STRING para reordenamiento interno + URIs de file managers ──
        let drop_target = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY);
        {
            let s2 = s.clone();
            let home_pl2 = home.clone();
            drop_target.connect_drop(move |_target, value, _x, y_pos| {
                let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4");
                let insert_at = (y_pos / 30.0) as usize;

                let text = match value.get::<String>() {
                    Ok(t) => t,
                    Err(_) => return false,
                };
                let text = text.trim().to_string();

                if text.starts_with("playlist-row:") {
                    // ── Reordenamiento interno ──
                    let from_idx: usize = match text["playlist-row:".len()..].parse() {
                        Ok(n) => n,
                        Err(_) => return false,
                    };
                    let mut st = s2.lock().unwrap();
                    if st.playlist.is_empty() { return false; }
                    let to_idx = insert_at.min(st.playlist.len().saturating_sub(1));
                    if from_idx == to_idx || from_idx >= st.playlist.len() {
                        return false;
                    }
                    let track = st.playlist.remove(from_idx);
                    let real_to = if from_idx < to_idx { to_idx - 1 } else { to_idx };
                    st.playlist.insert(real_to, track);
                    save_playlist_to_file(&st.playlist, &playlist_path);
                    st.playlist_version = st.playlist_version.wrapping_add(1);
                    info!("Reordenado: {} → {}", from_idx, real_to);
                    true
                } else {
                    // ── URI como texto (text/uri-list como STRING fallback) ──
                    let paths: Vec<PathBuf> = text.lines()
                        .map(|l| l.trim().trim_start_matches("file://").to_string())
                        .filter(|s| !s.is_empty())
                        .map(PathBuf::from)
                        .collect();
                    let mut st = s2.lock().unwrap();
                    let at = insert_at.min(st.playlist.len());
                    let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path);
                    inserted > 0
                }
            });
        }
        playlist_listbox.add_controller(drop_target);

        // DropTarget para FileList (múltiples archivos desde Nemo/Thunar)
        let drop_files = DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY);
        {
            let s2 = s.clone();
            let home_pl2 = home.clone();
            drop_files.connect_drop(move |_target, value, _x, y_pos| {
                if let Ok(file_list) = value.get::<FileList>() {
                    let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4");
                    let insert_at = (y_pos / 30.0) as usize;

                    let paths: Vec<PathBuf> = file_list.files()
                        .into_iter()
                        .filter_map(|f| f.path())
                        .collect();

                    if paths.is_empty() { return false; }

                    let mut st = s2.lock().unwrap();
                    let at = insert_at.min(st.playlist.len());
                    let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path);
                    info!("Drop externo FileList: {} archivos insertados en {}", inserted, at);
                    return inserted > 0;
                }
                false
            });
        }
        playlist_listbox.add_controller(drop_files);

        // ── DropTargets sobre el ScrolledWindow ───────────────────────────────
        // Cubren el área visible más allá de la última fila del ListBox
        // (un ListBox solo asigna alto al contenido; debajo queda viewport vacío).
        // Si el cursor está sobre una fila, los DropTargets del listbox capturan
        // primero; si está en el área vacía, llegan estos y se inserta al final.
        let drop_target_scroll = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY);
        {
            let s2 = s.clone();
            let home_pl2 = home.clone();
            drop_target_scroll.connect_drop(move |_target, value, _x, _y_pos| {
                let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4");

                let text = match value.get::<String>() {
                    Ok(t) => t,
                    Err(_) => return false,
                };
                let text = text.trim().to_string();

                // Reordenamiento interno: ignorar si llegó al área vacía
                if text.starts_with("playlist-row:") { return false; }

                let paths: Vec<PathBuf> = text.lines()
                    .map(|l| l.trim().trim_start_matches("file://").to_string())
                    .filter(|s| !s.is_empty())
                    .map(PathBuf::from)
                    .collect();
                let mut st = s2.lock().unwrap();
                let at = st.playlist.len();
                let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path);
                inserted > 0
            });
        }
        playlist_scroll.add_controller(drop_target_scroll);

        let drop_files_scroll = DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY);
        {
            let s2 = s.clone();
            let home_pl2 = home.clone();
            drop_files_scroll.connect_drop(move |_target, value, _x, _y_pos| {
                if let Ok(file_list) = value.get::<FileList>() {
                    let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4");
                    let paths: Vec<PathBuf> = file_list.files()
                        .into_iter()
                        .filter_map(|f| f.path())
                        .collect();
                    if paths.is_empty() { return false; }
                    let mut st = s2.lock().unwrap();
                    let at = st.playlist.len();
                    let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path);
                    info!("Drop scroll FileList: {} archivos al final", inserted);
                    return inserted > 0;
                }
                false
            });
        }
        playlist_scroll.add_controller(drop_files_scroll);

        // ── DropTargets sobre el Frame ────────────────────────────────────────
        // Cobertura final: el Frame es el contenedor más externo del panel y
        // siempre cubre toda el área visible (incluido el header de la lista).
        // Si por alguna razón el ScrolledWindow no recibe el evento (p.ej. al
        // ampliar la ventana después de iniciar el drag), el Frame sí.
        let drop_target_frame = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY);
        {
            let s2 = s.clone();
            let home_pl2 = home.clone();
            drop_target_frame.connect_drop(move |_target, value, _x, _y_pos| {
                let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4");
                let text = match value.get::<String>() {
                    Ok(t) => t,
                    Err(_) => return false,
                };
                let text = text.trim().to_string();
                if text.starts_with("playlist-row:") { return false; }
                let paths: Vec<PathBuf> = text.lines()
                    .map(|l| l.trim().trim_start_matches("file://").to_string())
                    .filter(|s| !s.is_empty())
                    .map(PathBuf::from)
                    .collect();
                let mut st = s2.lock().unwrap();
                let at = st.playlist.len();
                let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path);
                inserted > 0
            });
        }
        playlist_frame.add_controller(drop_target_frame);

        let drop_files_frame = DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY);
        {
            let s2 = s.clone();
            let home_pl2 = home.clone();
            drop_files_frame.connect_drop(move |_target, value, _x, _y_pos| {
                if let Ok(file_list) = value.get::<FileList>() {
                    let playlist_path = home_pl2.join(".gradio/data/tmp/playlist4");
                    let paths: Vec<PathBuf> = file_list.files()
                        .into_iter()
                        .filter_map(|f| f.path())
                        .collect();
                    if paths.is_empty() { return false; }
                    let mut st = s2.lock().unwrap();
                    let at = st.playlist.len();
                    let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path);
                    info!("Drop frame FileList: {} archivos al final", inserted);
                    return inserted > 0;
                }
                false
            });
        }
        playlist_frame.add_controller(drop_files_frame);
    }

    // ── Doble clic sobre una fila: reproducir con crossfade ──────────────────
    {
        let s = state.clone();
        playlist_listbox.connect_row_activated(move |_lb, row| {
            let idx = row.index() as usize;
            let target = {
                let st = s.lock().unwrap();
                match st.active_deck {
                    ActiveDeck::A => ActiveDeck::B,
                    ActiveDeck::B => ActiveDeck::A,
                }
            };
            play_track_to_deck(s.clone(), idx, target);
        });
    }

    // Botón Automático — pausa/reanuda playlist-refill (robot.png)
    // Verde oscuro = automático activo (estado natural)
    // Rojo oscuro  = llenado automático pausado (operador controla)
    let btn_auto_refill = icon_toggle_button(&icon_robot(), i18n::tr("tool.auto_refill"), 28);
    btn_auto_refill.add_css_class("refill-auto-btn");
    {
        let pause_file_init = home.join(".gradio/data/tmp/pause_refill");
        btn_auto_refill.set_active(pause_file_init.exists());
    }
    {
        let home_ar = home.clone();
        btn_auto_refill.connect_toggled(move |btn| {
            let pause_file = home_ar.join(".gradio/data/tmp/pause_refill");
            if btn.is_active() {
                let _ = std::fs::write(&pause_file, "");
                info!("playlist-refill: llenado automático PAUSADO");
            } else {
                let _ = std::fs::remove_file(&pause_file);
                info!("playlist-refill: llenado automático ACTIVO");
            }
        });
    }

    // Botón Vaciar parrilla — borra la cola de reproducción
    let btn_vaciar_playlist = icon_button(&icon_vaciar(), i18n::tr("tool.vaciar_playlist"), 28);
    btn_vaciar_playlist.add_css_class("vaciar-playlist-btn");
    {
        let home_vp = home.clone();
        let state_vp = state.clone();
        btn_vaciar_playlist.connect_clicked(move |_| {
            let playlist_path = home_vp.join(".gradio/data/tmp/playlist4");
            let mut st = state_vp.lock().unwrap();
            st.playlist.clear();
            save_playlist_to_file(&st.playlist, &playlist_path);
            st.playlist_version = st.playlist_version.wrapping_add(1);
            info!("Cola de reproducción vaciada manualmente");
        });
    }

    // Cabecera del panel playlist: label + duración total + botones (vaciar + automático)
    let playlist_header = GtkBox::new(Orientation::Horizontal, 4);
    playlist_header.add_css_class("playlist-header");
    let lbl_playlist_title = gtk4::Label::new(Some(i18n::tr("playlist.titulo")));
    lbl_playlist_title.set_hexpand(true);
    lbl_playlist_title.set_halign(gtk4::Align::Start);
    let lbl_total_dur = gtk4::Label::new(Some(&format!("{}: 00:00", i18n::tr("playlist.total"))));
    lbl_total_dur.add_css_class("playlist-total-dur");
    lbl_total_dur.set_halign(gtk4::Align::End);
    playlist_header.append(&lbl_playlist_title);
    playlist_header.append(&lbl_total_dur);
    playlist_header.append(&btn_vaciar_playlist);
    playlist_header.append(&btn_auto_refill);

    let playlist_vbox = GtkBox::new(Orientation::Vertical, 0);
    playlist_vbox.set_vexpand(true);
    playlist_vbox.set_valign(gtk4::Align::Fill);
    playlist_vbox.append(&playlist_header);
    playlist_vbox.append(&playlist_scroll);
    playlist_frame.set_child(Some(&playlist_vbox));

    // Panel 1: Próximas tandas comerciales
    let comerciales_outer_frame = Frame::new(Some(i18n::tr("panel.comerciales_tandas")));
    comerciales_outer_frame.add_css_class("list-frame");
    comerciales_outer_frame.set_hexpand(true);
    comerciales_outer_frame.set_vexpand(true);

    let comerciales_scroll = ScrolledWindow::new();
    comerciales_scroll.set_vexpand(true);
    comerciales_scroll.set_min_content_height(120);

    // Box que contiene las 4 tarjetas de tanda
    let tandas_box = GtkBox::new(Orientation::Vertical, 4);
    tandas_box.set_margin_start(4);
    tandas_box.set_margin_end(4);
    tandas_box.set_margin_top(4);
    tandas_box.set_margin_bottom(4);
    tandas_box.set_vexpand(true);

    comerciales_scroll.set_child(Some(&tandas_box));
    comerciales_outer_frame.set_child(Some(&comerciales_scroll));

    // Panel 2: Cola de eventos en espera (eventos-esperalist)
    let espera_frame = Frame::new(Some(i18n::tr("panel.eventos_espera")));
    espera_frame.add_css_class("list-frame");
    espera_frame.set_hexpand(true);
    espera_frame.set_vexpand(true);
    let espera_listbox = ListBox::new();
    espera_listbox.add_css_class("queue-list");
    espera_listbox.set_selection_mode(gtk4::SelectionMode::None);
    let espera_scroll = ScrolledWindow::new();
    espera_scroll.set_vexpand(true);
    espera_scroll.set_min_content_height(40);
    espera_scroll.set_child(Some(&espera_listbox));
    espera_frame.set_child(Some(&espera_scroll));

    // Panel 3: Cola de eventos (eventoslist)
    let eventos_frame = Frame::new(Some(i18n::tr("panel.eventos")));
    eventos_frame.add_css_class("list-frame");
    eventos_frame.set_hexpand(true);
    eventos_frame.set_vexpand(true);
    let eventos_listbox = ListBox::new();
    eventos_listbox.add_css_class("queue-list");
    eventos_listbox.set_selection_mode(gtk4::SelectionMode::None);
    let eventos_scroll = ScrolledWindow::new();
    eventos_scroll.set_vexpand(true);
    eventos_scroll.set_min_content_height(40);
    eventos_scroll.set_child(Some(&eventos_listbox));
    eventos_frame.set_child(Some(&eventos_scroll));

    // ── Layout derecho: Paned redimensionable ─────────────────────────────────
    // paned_eventos: separa espera (arriba) de eventos (abajo)
    let paned_eventos = gtk4::Paned::new(Orientation::Vertical);
    paned_eventos.set_hexpand(true);
    paned_eventos.set_vexpand(true);
    paned_eventos.set_wide_handle(true);
    paned_eventos.set_start_child(Some(&espera_frame));
    paned_eventos.set_end_child(Some(&eventos_frame));
    paned_eventos.set_position(120);
    // Ocultar hasta que haya eventos/espera
    paned_eventos.set_visible(false);

    // paned_right: comerciales (arriba) | bloque de eventos (abajo)
    let paned_right = gtk4::Paned::new(Orientation::Vertical);
    paned_right.set_hexpand(true);
    paned_right.set_vexpand(true);
    paned_right.set_wide_handle(true);
    paned_right.set_start_child(Some(&comerciales_outer_frame));
    paned_right.set_end_child(Some(&paned_eventos));
    // paned_eventos.set_visible(false) hace que start_child ocupe todo el espacio

    // ── Menús contextuales (clic derecho) para vaciar colas ──────────────────
    agregar_menu_vaciar(&espera_frame, "Eventos en espera", home.join(".gradio/data/tmp/eventos-esperalist"), state.clone());
    agregar_menu_vaciar(&eventos_frame, "Eventos", home.join(".gradio/data/tmp/eventoslist"), state.clone());

    lists_row.append(&playlist_frame);
    lists_row.append(&paned_right);

    // ── DropTarget catch-all sobre lists_row ─────────────────────────────────
    // Si la ventana se extiende tras abrir el buscador y los DropTargets
    // internos (listbox/scroll/frame) no reciben el evento por la geometría
    // resultante, este target captura el drop y rutea por coordenada x: si
    // x cae dentro del playlist_frame → playlist; si no → ignorar (los
    // DropTargets de comerciales se encargan de su lado).
    {
        use gtk4::prelude::WidgetExt;
        let drop_lr_str = DropTarget::new(glib::Type::STRING, gdk4::DragAction::MOVE | gdk4::DragAction::COPY);
        {
            let s2 = state.clone();
            let home_lr = home.clone();
            let frame_w = playlist_frame.clone();
            drop_lr_str.connect_drop(move |_target, value, x, _y| {
                let pl_right = (frame_w.allocation().x() + frame_w.allocation().width()) as f64;
                if x >= pl_right { return false; }
                let text = match value.get::<String>() { Ok(t) => t, Err(_) => return false };
                let text = text.trim().to_string();
                if text.starts_with("playlist-row:") { return false; }
                let paths: Vec<PathBuf> = text.lines()
                    .map(|l| l.trim().trim_start_matches("file://").to_string())
                    .filter(|s| !s.is_empty())
                    .map(PathBuf::from)
                    .collect();
                let playlist_path = home_lr.join(".gradio/data/tmp/playlist4");
                let mut st = s2.lock().unwrap();
                let at = st.playlist.len();
                insert_paths_validated(paths, at, &mut st, &playlist_path) > 0
            });
        }
        lists_row.add_controller(drop_lr_str);

        let drop_lr_files = DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY);
        {
            let s2 = state.clone();
            let home_lr = home.clone();
            let frame_w = playlist_frame.clone();
            drop_lr_files.connect_drop(move |_target, value, x, _y| {
                let pl_right = (frame_w.allocation().x() + frame_w.allocation().width()) as f64;
                if x >= pl_right { return false; }
                if let Ok(file_list) = value.get::<FileList>() {
                    let paths: Vec<PathBuf> = file_list.files()
                        .into_iter()
                        .filter_map(|f| f.path())
                        .collect();
                    if paths.is_empty() { return false; }
                    let playlist_path = home_lr.join(".gradio/data/tmp/playlist4");
                    let mut st = s2.lock().unwrap();
                    let at = st.playlist.len();
                    let inserted = insert_paths_validated(paths, at, &mut st, &playlist_path);
                    info!("Drop lists_row FileList: {} archivos al final", inserted);
                    return inserted > 0;
                }
                false
            });
        }
        lists_row.add_controller(drop_lr_files);
    }

    root.append(&lists_row);

    // ── Seek de comerciales ──
    {
        let s = state.clone();
        comm_seek.connect_change_value(move |_scale, _scroll, value| {
            let st = s.lock().unwrap();
            if let Some(ref p) = st.pipeline_comm {
                pipeline_seek(p, value);
            }
            glib::Propagation::Proceed
        });
    }

    // ── Señal botón Hora ──
    {
        let s = state.clone();
        btn_hora.connect_clicked(move |_| {
            let state_h = s.clone();
            glib::spawn_future_local(async move {
                play_hora(&state_h).await;
            });
        });
    }

    // ── Botón Pisador ──
    {
        let s = state.clone();
        let home_p = home.clone();
        btn_pisador.connect_clicked(move |_| {
            let state_p = s.clone();
            let home_pp = home_p.clone();
            glib::spawn_future_local(async move {
                play_pisador(&state_p, &home_pp).await;
            });
        });
    }

    // ── Señales botones externos ──
    {
        btn_pautaje.connect_clicked(move |_| {
            let exe_dir = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
                .unwrap_or_else(|| std::path::PathBuf::from("."));
            let prog = exe_dir.join(format!("gr-pautaje{}", EXE_EXT));
            if let Err(e) = std::process::Command::new(&prog).spawn() {
                error!("No se pudo lanzar gr-pautaje ({:?}): {}", prog, e);
            }
        });
    }
    {
        btn_parrilla.connect_clicked(move |_| {
            let exe_dir = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
                .unwrap_or_else(|| std::path::PathBuf::from("."));
            let prog = exe_dir.join(format!("gr-parrilla{}", EXE_EXT));
            if let Err(e) = std::process::Command::new(&prog).spawn() {
                error!("No se pudo lanzar gr-parrilla ({:?}): {}", prog, e);
            }
        });
    }
    {
        btn_botonera.connect_clicked(move |_| {
            let exe_dir = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
                .unwrap_or_else(|| std::path::PathBuf::from("."));
            let prog = exe_dir.join(format!("gr-botonera{}", EXE_EXT));
            if let Err(e) = std::process::Command::new(&prog).spawn() {
                error!("No se pudo lanzar gr-botonera ({:?}): {}", prog, e);
            }
        });
    }
    {
        let win_cfg = window.upcast_ref::<gtk4::Window>().clone();
        btn_config.connect_clicked(move |_| {
            show_config_dialog(&win_cfg);
        });
    }
    {
        btn_visor.connect_clicked(move |_| {
            let exe_dir = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
                .unwrap_or_else(|| std::path::PathBuf::from("."));
            let prog = exe_dir.join(format!("gr-visor{}", EXE_EXT));
            if let Err(e) = std::process::Command::new(&prog).spawn() {
                error!("No se pudo lanzar gr-visor ({:?}): {}", prog, e);
            }
        });
    }
    {
        let home_g = home.clone();
        btn_record.connect_clicked(move |_| {
            let exe = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.join(format!("gr-record{}", EXE_EXT))))
                .unwrap_or_else(|| std::path::PathBuf::from(format!("gr-record{}", EXE_EXT)));
            if let Err(e) = std::process::Command::new(&exe).spawn() {
                error!("No se pudo lanzar gr-record: {}", e);
            }
        });
    }
    {
        let home_g = home.clone();
        btn_operador.connect_clicked(move |_| {
            let prog = home_g.join(".gradio/bin/GR-Operador.gambas");
            if let Err(e) = std::process::Command::new(&prog).spawn() {
                error!("No se pudo lanzar Operador: {}", e);
            }
        });
    }
    {
        let s_vu = state.clone();
        btn_vumeter.connect_clicked(move |btn| {
            let mut st = s_vu.lock().unwrap();
            st.vu_active = !st.vu_active;
            // Indicar visualmente si está activo
            if st.vu_active {
                btn.remove_css_class("deck-icon-btn-active");
                btn.add_css_class("global-icon-btn");
            } else {
                btn.remove_css_class("global-icon-btn");
                btn.add_css_class("deck-icon-btn-active");
            }
            info!("VU meter: {}", if st.vu_active { "activado" } else { "desactivado" });
        });
    }

    // ── Señal Duck toggle ──
    let s = state.clone();
    {
        btn_buscador.connect_clicked(move |_| {
            let exe_dir = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
                .unwrap_or_else(|| std::path::PathBuf::from("."));
            let prog = exe_dir.join(format!("gr-buscador{}", EXE_EXT));
            if let Err(e) = std::process::Command::new(&prog).spawn() {
                error!("No se pudo lanzar gr-buscador ({:?}): {}", prog, e);
            }
        });
    }
    {
        btn_playlist.connect_clicked(move |_| {
            let exe_dir = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
                .unwrap_or_else(|| std::path::PathBuf::from("."));
            let prog = exe_dir.join(format!("gr-playlist{}", EXE_EXT));
            if let Err(e) = std::process::Command::new(&prog).spawn() {
                error!("No se pudo lanzar gr-playlist ({:?}): {}", prog, e);
            }
        });
    }

    {
        btn_reportes.connect_clicked(move |_| {
            let exe_dir = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
                .unwrap_or_else(|| std::path::PathBuf::from("."));
            let prog = exe_dir.join(format!("gr-reportes{}", EXE_EXT));
            if let Err(e) = std::process::Command::new(&prog).spawn() {
                error!("No se pudo lanzar gr-reportes ({:?}): {}", prog, e);
            }
        });
    }

    duck_btn.connect_toggled(move |btn| {
        let mut st = s.lock().unwrap();
        st.duck_active = btn.is_active();
        let target_vol = if btn.is_active() { st.downvol } else { st.upvol };
        for pipeline in [&st.pipeline_a, &st.pipeline_b].into_iter().flatten() {
            set_pipeline_volume(pipeline, target_vol);
        }
        // Actualizar icono según estado
        let icon = if btn.is_active() { icon_fadeout_on() } else { icon_fadeout_off() };
        let loader = gdk4::gdk_pixbuf::PixbufLoader::new();
        loader.write(&icon).unwrap_or(());
        loader.close().unwrap_or(());
        if let Some(pb) = loader.pixbuf() {
            if let Some(sc) = pb.scale_simple(26, 26, gdk4::gdk_pixbuf::InterpType::Bilinear) {
                let tex = gdk4::Texture::for_pixbuf(&sc);
                let img = gtk4::Image::from_paintable(Some(&tex));
                btn.set_child(Some(&img));
            }
        }
    });

    // ── Iniciar Playlist ──
    let s = state.clone();
    btn_start.connect_clicked(move |_| {
        start_next_track(s.clone(), 0);
    });

    // ── Siguiente tema ──
    let s = state.clone();
    btn_next.connect_clicked(move |_| {
        // Interrumpir streaming en curso si lo hay
        s.lock().unwrap().stream_skip = true;
        let next_idx = {
            let st = s.lock().unwrap();
            if st.playlist.is_empty() {
                return;
            }
            st.current_index % st.playlist.len()
        };
        start_next_track(s.clone(), next_idx);
    });

    // ── Timer de actualización UI (cada 500ms) ──
    let s = state.clone();
    let title_a_c = title_a.clone();
    let title_b_c = title_b.clone();
    let time_a_c = time_a.clone();
    let time_b_c = time_b.clone();
    let total_a_c = total_a.clone();
    let total_b_c = total_b.clone();
    let adj_a_c = adj_a.clone();
    let adj_b_c = adj_b.clone();
    let clock_c = clock_label.clone();
    let status_c = status_label.clone();
    let lbl_station_c = lbl_station.clone();
    let now_playing_c = now_playing.clone();
    let vol_info_c = vol_info.clone();
    let btn_start_c = btn_start.clone();
    let btn_ia_c = btn_ia.clone();
    let mut last_gr_on = false;

    // Clones para el timer
    let playlist_listbox_c = playlist_listbox.clone();
    let espera_listbox_c = espera_listbox.clone();
    let eventos_listbox_c = eventos_listbox.clone();
    let tandas_box_c = tandas_box.clone();
    let paned_eventos_c = paned_eventos.clone();
    let comm_title_c = comm_title.clone();
    let comm_time_c = comm_time.clone();
    let comm_total_c = comm_total.clone();
    let comm_adj_c = comm_adj.clone();
    let deck_a_frame_c = deck_a_frame.clone();
    let deck_b_frame_c = deck_b_frame.clone();
    let comm_bar_c = comm_bar.clone();

    // Reload vol files periodically
    let home_c = home.clone();
    let win_c = window.clone();

    // Clones para el VU meter
    let vu_bar_l_c   = vu_bar_l.clone();
    let vu_bar_r_c   = vu_bar_r.clone();
    let vu_level_l_c = vu_level_l_draw.clone();
    let vu_level_r_c = vu_level_r_draw.clone();
    let vu_active_c  = vu_active_draw.clone();

    // Clones para botones "Detener al final" (para actualizar CSS desde el timer)
    let btn_stop_end_a_c = btn_stop_end_a.clone();
    let btn_stop_end_b_c = btn_stop_end_b.clone();

    let lbl_cliente_c = lbl_cliente.clone();
    let cliente_conectado_c = cliente_conectado.clone();
    let lbl_total_dur_c = lbl_total_dur.clone();
    let mut last_playlist_version: u64 = 0;
    let mut last_playlist_signature: String = String::new();
    let mut last_playlist_mtime = std::time::SystemTime::UNIX_EPOCH;
    let mut last_current_song = String::new();
    // Snapshot del contenido de cada listbox de cola (no del archivo) — se usa para
    // detectar si cambió y rearmar las filas con duración + botones up/down.
    let mut last_signature_espera: String = String::new();
    let mut last_signature_evt:    String = String::new();
    let mut last_sig_comlist:      String = String::new(); // watcher cola activa
    let mut upcoming_refresh_counter: u32 = 300; // forzar refresh inmediato
    // Último tooltip aplicado a cada título de deck — set_tooltip_text() repetido
    // en cada tick (incluso con el mismo valor) reinicia el temporizador de hover
    // de GTK para TODA la ventana, matando los tooltips de toda la app. Solo se
    // debe llamar cuando el valor realmente cambia.
    let mut last_tooltip_a: Option<String> = None;
    let mut last_tooltip_b: Option<String> = None;
    let mut last_tooltip_now: Option<String> = None;
    timeout_add_local(Duration::from_millis(200), move || {
        // Reloj
        clock_c.set_text(&Local::now().format("%H:%M:%S").to_string());

        // ── Cliente remoto conectado ──────────────────────────────────────────
        {
            let texto = cliente_conectado_c.lock().unwrap().clone();
            lbl_cliente_c.set_text(&texto);
        }

        // ── Play de tanda comercial (IPC vía archivo cmd_play_tanda) ─────────────
        // Escenario 1: tema en reproducción → fadeout → tanda → playlist
        // Escenario 2: detenido (stop_after_track ya disparó) → tanda → playlist
        {
            let cmd_tanda = home_c.join(".gradio/data/tmp/cmd_play_tanda");
            if cmd_tanda.exists() {
                let _ = fs::remove_file(&cmd_tanda);
                let state_t  = s.clone();
                let home_t   = home_c.clone();
                glib::spawn_future_local(async move {
                    let comerc_path = home_t.join(".gradio/data/tmp/comercialeslist4");
                    let commercials = load_commercials(&comerc_path);
                    if !commercials.is_empty() {
                        // Fadeout del deck activo antes de la tanda
                        let crossfade = { state_t.lock().unwrap().crossfade_secs };
                        fadeout_active_deck(&state_t, crossfade).await;
                        let idx = { state_t.lock().unwrap().current_index };
                        play_commercials_then_track(state_t, commercials, vec![], idx).await;
                    }
                });
                return glib::ControlFlow::Continue;
            }
        }

        // ── Botonera remota (IPC vía archivo botonera_now) ───────────────────────
        {
            let botonera_now_file = home_c.join(".gradio/data/tmp/botonera_now");
            if let Ok(ruta) = fs::read_to_string(&botonera_now_file) {
                let _ = fs::remove_file(&botonera_now_file);
                let ruta = ruta.trim().to_string();
                if !ruta.is_empty() {
                    let state_b = s.clone();
                    glib::spawn_future_local(async move {
                        play_botonera_file(&state_b, &ruta).await;
                    });
                    return glib::ControlFlow::Continue;
                }
            }
        }

        // ── Play Now desde el buscador (IPC vía archivo play_now) ────────────────
        {
            let play_now_file = home_c.join(".gradio/data/tmp/play_now");
            if let Ok(content) = fs::read_to_string(&play_now_file) {
                let _ = fs::remove_file(&play_now_file);
                let content = content.trim().to_string();
                if !content.is_empty() {
                    // Formato: "/ruta/archivo.mp3\tHH:MM:SS"
                    let mut parts = content.splitn(2, '\t');
                    let path_str = parts.next().unwrap_or("").trim().to_string();
                    let dur_str  = parts.next().unwrap_or("00:00:00").trim().to_string();
                    if !path_str.is_empty() {
                        let track_path = PathBuf::from(&path_str);
                        let title = track_path.file_stem()
                            .and_then(|s| s.to_str())
                            .unwrap_or("Audio")
                            .to_string();
                        // Convertir HH:MM:SS → segundos
                        let dur_secs: f64 = {
                            let p: Vec<&str> = dur_str.splitn(3, ':').collect();
                            match p.as_slice() {
                                [h, m, s] => {
                                    let hv: f64 = h.parse().unwrap_or(0.0);
                                    let mv: f64 = m.parse().unwrap_or(0.0);
                                    let sv: f64 = s.parse().unwrap_or(0.0);
                                    hv * 3600.0 + mv * 60.0 + sv
                                }
                                [m, s] => {
                                    let mv: f64 = m.parse().unwrap_or(0.0);
                                    let sv: f64 = s.parse().unwrap_or(0.0);
                                    mv * 60.0 + sv
                                }
                                _ => 0.0,
                            }
                        };
                        let track = Track { path: track_path.clone(), duration_secs: dur_secs, title };
                        // Insertar al frente de la playlist en memoria y en archivo
                        {
                            let mut st2 = s.lock().unwrap();
                            st2.playlist.insert(0, track);
                            st2.playlist_version = st2.playlist_version.wrapping_add(1);
                            st2.crossfade_triggered = false;
                            // Prepend al archivo playlist4
                            let playlist_path = home_c.join(".gradio/data/tmp/playlist4");
                            let existing = fs::read_to_string(&playlist_path).unwrap_or_default();
                            let new_line = format!("{}\t{}\n", track_path.display(), dur_str);
                            let _ = fs::write(&playlist_path, format!("{}{}", new_line, existing));
                        }
                        info!("play_now: crossfade hacia '{}'", path_str);
                        start_next_track(s.clone(), 0);
                        return glib::ControlFlow::Continue;
                    }
                }
            }
        }

        // ── Comandos remotos de gr-client ──────────────────────────────────────
        {
            let tmp = home_c.join(".gradio/data/tmp");
            for cmd in ["cmd_play", "cmd_pause", "cmd_stop", "cmd_stop_after",
                        "cmd_siguiente", "cmd_stop_general"] {
                let f = tmp.join(cmd);
                if f.exists() {
                    let _ = fs::remove_file(&f);
                    match cmd {
                        "cmd_play" => {
                            let mut st = s.lock().unwrap();
                            action_play(&mut st, ActiveDeck::A);
                        }
                        "cmd_pause" => {
                            let mut st = s.lock().unwrap();
                            action_pause(&mut st, ActiveDeck::A);
                        }
                        "cmd_stop" => {
                            let mut st = s.lock().unwrap();
                            action_stop(&mut st, ActiveDeck::A);
                        }
                        "cmd_stop_after" => {
                            let mut st = s.lock().unwrap();
                            st.stop_after_track = !st.stop_after_track;
                        }
                        "cmd_siguiente" => {
                            s.lock().unwrap().stream_skip = true;
                            let next_idx = {
                                let st = s.lock().unwrap();
                                if st.playlist.is_empty() { continue; }
                                st.current_index % st.playlist.len()
                            };
                            start_next_track(s.clone(), next_idx);
                        }
                        "cmd_stop_general" => {
                            interrupt_pautaje(&s);
                            let mut st = s.lock().unwrap();
                            action_stop(&mut st, ActiveDeck::A);
                            action_stop(&mut st, ActiveDeck::B);
                            info!("Stop General activado por comando remoto");
                        }
                        _ => {}
                    }
                }
            }
        }

        let mut st = s.lock().unwrap();

        // ── Detección de suspensión del GLib main loop (pantalla en blanco ARM/Wayland) ──
        // En GTK4+Wayland, cuando el compositor apaga la pantalla puede congelar el main
        // loop. Al reanudar, `elapsed()` acumula todo el tiempo de pausa y los detectores
        // de silencio / drift disparan en falso. Si el gap entre ticks supera 2s
        // reseteamos los contadores para evitar avance de tema incorrecto.
        {
            let timer_gap = st.last_timer_tick.elapsed().as_secs_f64();
            if timer_gap > 2.0 {
                warn!("Timer gap {:.1}s detectado — reseteando detectores de silencio (ARM/screen-blank)", timer_gap);
                st.silence_since_a = None;
                st.stuck_since_a   = None;
                st.stuck_pos_a     = -1.0;
                st.silence_since_b = None;
                st.stuck_since_b   = None;
                st.stuck_pos_b     = -1.0;
                // Actualizar last_level_msg para que Via 1 (nivel) no lo considere stale
                st.vu_last_level_msg = std::time::Instant::now();
            }
            st.last_timer_tick = std::time::Instant::now();
        }

        // Re-leer volúmenes desde archivos
        let new_upvol = read_f64_from_file(&home_c.join(".gradio/data/tmp/upvol")).clamp(0.0, 100.0) / 100.0;
        let new_downvol = read_f64_from_file(&home_c.join(".gradio/data/tmp/downvol")).clamp(0.0, 100.0) / 100.0;

        if (new_upvol - st.upvol).abs() > 0.005 {
            st.upvol = new_upvol;
            if !st.duck_active {
                for p in [&st.pipeline_a, &st.pipeline_b].into_iter().flatten() {
                    set_pipeline_volume(p, new_upvol);
                }
            }
        }
        if (new_downvol - st.downvol).abs() > 0.005 {
            st.downvol = new_downvol;
        }

        // Re-leer configuración en cada ciclo (por si cambió desde el panel)
        let gr_cfg_now = read_gradio_config();
        st.crossfade_secs = gr_cfg_now.crossfade_secs;
        st.silence_secs   = gr_cfg_now.silence_secs;
        st.station_name   = gr_cfg_now.station_name.clone();

        vol_info_c.set_text(&i18n::tr("vol.fmt")
            .replace("{up}", &format!("{:.0}", st.upvol * 100.0))
            .replace("{dn}", &format!("{:.0}", st.downvol * 100.0))
            .replace("{mix}", &format!("{:.1}", st.crossfade_secs)));
        btn_ia_c.set_visible(gr_cfg_now.ia_habilitada);

        // ── Ícono GR-On / GR-Off según estado de reproducción ──
        let is_playing = st.deck_a_state == PlaybackState::Playing
            || st.deck_b_state == PlaybackState::Playing;
        if is_playing != last_gr_on {
            last_gr_on = is_playing;
            let icon_bytes = if is_playing { icon_gr_on() } else { icon_gr_off() };
            set_button_icon(&btn_start_c, &icon_bytes, 36);
        }

        // ── Detección de eventos emergentes ──
        // Si eventoslist tiene contenido y no estamos ya procesándolo → lanzar
        if !st.eventos_playing && !st.commercials_playing {
            let eventos_path = home_c.join(".gradio/data/tmp/eventoslist");
            let tiene_eventos = fs::read_to_string(&eventos_path)
                .map(|s| s.lines().any(|l| !l.trim().is_empty()))
                .unwrap_or(false);
            if tiene_eventos {
                st.eventos_playing = true;
                let state_ev = s.clone();
                drop(st);
                glib::spawn_future_local(async move {
                    play_eventos_emergentes(state_ev).await;
                });
                return glib::ControlFlow::Continue;
            }
        }

        // ── Actualizar barra de comerciales ──
        if let Some(ref p) = st.pipeline_comm {
            let pos = pipeline_position(p);
            let dur = pipeline_duration(p).max(1.0);
            comm_time_c.set_text(&secs_to_display(pos));
            comm_total_c.set_text(&secs_to_display(dur));
            comm_adj_c.set_upper(dur);
            comm_adj_c.set_value(pos);
            // Mostrar nombre del audio en curso en la barra superior y en comm_title
            if !st.comm_title.is_empty() {
                now_playing_c.set_text(&format!("📢  {}", st.comm_title));
                comm_title_c.set_text(&st.comm_title);
            }
        } else {
            comm_time_c.set_text("00:00");
            comm_total_c.set_text("00:00");
            comm_adj_c.set_upper(100.0);
            comm_adj_c.set_value(0.0);
            // Limpiar título y current_title cuando no hay nada reproduciendo
            if !st.comm_title.is_empty() {
                st.comm_title = String::new();
            }
            comm_title_c.set_text(i18n::tr("now.no_ad"));
        }

        // ── Players: mostrar solo el panel correspondiente al modo elegido ──
        if players_mode != 3 {
            let comercial_activo = players_mode == 1 && st.pipeline_comm.is_some();
            if comercial_activo {
                deck_a_frame_c.set_visible(false);
                deck_b_frame_c.set_visible(false);
                comm_bar_c.set_visible(true);
            } else {
                let is_a = st.active_deck == ActiveDeck::A;
                deck_a_frame_c.set_visible(is_a);
                deck_b_frame_c.set_visible(!is_a);
                if players_mode == 1 {
                    comm_bar_c.set_visible(false);
                }
            }
        }

        // ── Actualizar barras VU desde AppState (llenado por bus_watch) ──────────
        let (lev_l, lev_r) = if st.vu_active {
            st.vu_level_l = (st.vu_level_l * 0.85).max(0.0);
            st.vu_level_r = (st.vu_level_r * 0.85).max(0.0);
            vu_active_c.set(true);
            (st.vu_level_l, st.vu_level_r)
        } else {
            st.vu_level_l = 0.0;
            st.vu_level_r = 0.0;
            vu_active_c.set(false);
            (0.0f64, 0.0f64)
        };
        vu_level_l_c.set(lev_l);
        vu_level_r_c.set(lev_r);
        vu_bar_l_c.queue_draw();
        vu_bar_r_c.queue_draw();

        // ── Actualizar CSS botón "Detener al final" ──
        for btn in [&btn_stop_end_a_c, &btn_stop_end_b_c] {
            if st.stop_after_track {
                if !btn.has_css_class("deck-icon-btn-stop") {
                    btn.remove_css_class("deck-icon-btn");
                    btn.add_css_class("deck-icon-btn-stop");
                }
            } else {
                if btn.has_css_class("deck-icon-btn-stop") {
                    btn.remove_css_class("deck-icon-btn-stop");
                    btn.add_css_class("deck-icon-btn");
                }
            }
        }

        // ── Actualizar Deck A ──
        {
            let (pos, dur, bus_opt, has_pipeline) = if let Some(ref p) = st.pipeline_a {
                (pipeline_position(p), pipeline_duration(p).max(1.0), p.bus(), true)
            } else {
                // Sin pipeline: reset visual a cero
                if st.stopped_after_a {
                    title_a_c.set_text(i18n::tr("now.stopped_after"));
                } else {
                    title_a_c.set_text(i18n::tr("now.no_track"));
                }
                if last_tooltip_a.is_some() {
                    last_tooltip_a = None;
                    title_a_c.set_tooltip_text(None::<&str>);
                }
                time_a_c.set_text("00:00");
                total_a_c.set_text("00:00");
                adj_a_c.set_upper(100.0);
                adj_a_c.set_value(0.0);
                (0.0, 0.0, None, false)
            };
            if has_pipeline {
                time_a_c.set_text(&secs_to_display(pos));
                total_a_c.set_text(&secs_to_display(dur));
                adj_a_c.set_upper(dur);
                adj_a_c.set_value(pos);
            }
            // ── Detector de silencio / posición estancada — Deck A ───────────────
            if has_pipeline
                && st.active_deck == ActiveDeck::A
                && st.deck_a_state == PlaybackState::Playing
                && !st.commercials_playing
                && !st.eventos_playing
                && !st.crossfade_triggered
                && st.silence_secs > 0.0
            {
                // ── Vía 1: nivel de audio bajo -45 dB (solo si pos es válida) ──
                if pos > 2.0 {
                    let level_stale = st.vu_last_level_msg.elapsed().as_secs_f64() > 1.5;
                    let is_silent = level_stale
                        || (st.vu_level_l_raw < -45.0 && st.vu_level_r_raw < -45.0);
                    if is_silent {
                        if st.silence_since_a.is_none() {
                            st.silence_since_a = Some(std::time::Instant::now());
                        } else if st.silence_since_a.unwrap().elapsed().as_secs_f64() >= st.silence_secs {
                            warn!("Deck A: silencio sostenido {:.0}s — {}", st.silence_secs,
                                  if st.loop_deck_a { "reiniciando (loop)" } else if st.stop_after_track { "deteniendo" } else { "avanzando" });
                            st.silence_since_a = None; st.stuck_since_a = None;
                            if st.loop_deck_a {
                                if let Some(ref p) = st.pipeline_a {
                                    let _ = p.set_state(gst::State::Ready);
                                    let _ = p.set_state(gst::State::Playing);
                                }
                                st.current_track_start = std::time::Instant::now();
                            } else if st.stop_after_track {
                                st.stop_after_track = false;
                                st.stopped_after_a = true;
                                action_stop(&mut st, ActiveDeck::A);
                                now_playing_c.set_text(i18n::tr("now.stopped_end"));
                                return glib::ControlFlow::Continue;
                            } else {
                                st.crossfade_triggered = true;
                                let next = st.current_index % st.playlist.len().max(1);
                                drop(st);
                                start_next_track(s.clone(), next);
                                return glib::ControlFlow::Continue;
                            }
                        }
                    } else {
                        st.silence_since_a = None;
                    }
                }
                // ── Vía 2: reloj de pared vs posición GStreamer ──
                // Si el reloj real lleva silence_secs+ por delante de pos → pipeline pegado.
                // GUARD pos > 0: si query_position devuelve None (→0.0), p.ej. al despertar
                // de pantalla en blanco en ARM, el drift sería enorme y dispararía en falso.
                let wall = st.current_track_start.elapsed().as_secs_f64();
                let drift = wall - pos.max(0.0);
                if pos > 0.0 && wall > 8.0 && drift >= st.silence_secs {
                    warn!("Deck A: reloj={:.1}s pos={:.1}s drift={:.1}s — {}",
                          wall, pos, drift,
                          if st.loop_deck_a { "reiniciando (loop)" } else if st.stop_after_track { "deteniendo" } else { "avanzando" });
                    st.stuck_since_a = None; st.silence_since_a = None;
                    if st.loop_deck_a {
                        if let Some(ref p) = st.pipeline_a {
                            let _ = p.set_state(gst::State::Ready);
                            let _ = p.set_state(gst::State::Playing);
                        }
                        st.current_track_start = std::time::Instant::now();
                    } else if st.stop_after_track {
                        st.stop_after_track = false;
                        st.stopped_after_a = true;
                        action_stop(&mut st, ActiveDeck::A);
                        now_playing_c.set_text(i18n::tr("now.stopped_end"));
                        return glib::ControlFlow::Continue;
                    } else {
                        st.crossfade_triggered = true;
                        let next = st.current_index % st.playlist.len().max(1);
                        drop(st);
                        start_next_track(s.clone(), next);
                        return glib::ControlFlow::Continue;
                    }
                }
            } else {
                st.silence_since_a = None;
                st.stuck_since_a = None;
                st.stuck_pos_a = -1.0;
            }
            if let Some(bus) = bus_opt {
                // Cruzar por posición: lanzar siguiente deck cuando quedan crossfade_secs.
                // Guardia de 500ms: evita disparo falso si GStreamer aún reporta posición
                // antigua tras un seek (p.ej. después de pulsar Repetir).
                if has_pipeline
                    && st.active_deck == ActiveDeck::A
                    && !st.crossfade_triggered
                    && !st.commercials_playing
                    && !st.eventos_playing
                    && !st.stop_after_track
                    && dur > st.crossfade_secs.max(1.0)
                    && pos > 0.0
                    && st.current_track_start.elapsed().as_secs_f64() > 0.5
                    && (dur - pos) <= st.crossfade_secs.max(0.1)
                {
                    if !st.loop_deck_a {
                        let next = st.current_index % st.playlist.len().max(1);
                        let next_path = st.playlist.get(next).map(|t| t.path.clone()).unwrap_or_default();
                        let sin_fundir = fundido_omitido(&read_gradio_config(), &st.current_track_path, &next_path);
                        if !sin_fundir {
                            st.crossfade_triggered = true;
                            drop(st);
                            start_next_track(s.clone(), next);
                            return glib::ControlFlow::Continue;
                        }
                        // sin_fundir: carpeta excluida de crossfade — dejar llegar al
                        // EOS natural para no cortar el final del audio, el adelanto
                        // por posición sólo tiene sentido si se va a cruzar.
                    }
                    // loop_deck_a activo: dejar llegar al EOS natural, no hacer crossfade
                }
                // EOS o Error: avanzar al siguiente track
                let mut advance_deck_a = false;
                while let Some(msg) = bus.pop() {
                    use gst::MessageView;
                    match msg.view() {
                        MessageView::Eos(_) => {
                            if st.active_deck == ActiveDeck::A { advance_deck_a = true; }
                        }
                        MessageView::Error(e) => {
                            if st.active_deck == ActiveDeck::A {
                                warn!("Pipeline A error: {} — avanzando al siguiente", e.error());
                                advance_deck_a = true;
                                // Liberar pipeline para evitar errores repetidos en bus
                                if let Some(p) = st.pipeline_a.take() {
                                    shutdown_pipeline(p);
                                }
                                break;
                            }
                        }
                        _ => {}
                    }
                }
                if advance_deck_a {
                    if st.loop_deck_a {
                        // Loop: llevar a Ready reinicia todos los elementos (EOS queda limpio),
                        // luego Playing vuelve a reproducir desde el inicio.
                        if let Some(ref p) = st.pipeline_a {
                            let _ = p.set_state(gst::State::Ready);
                            let _ = p.set_state(gst::State::Playing);
                        }
                    } else if st.stop_after_track {
                        st.stop_after_track = false;
                        st.stopped_after_a = true;
                        action_stop(&mut st, ActiveDeck::A);
                        now_playing_c.set_text(i18n::tr("now.stopped_end"));
                    } else if !st.crossfade_triggered {
                        st.crossfade_triggered = true;
                        let next = st.current_index % st.playlist.len().max(1);
                        drop(st);
                        start_next_track(s.clone(), next);
                        return glib::ControlFlow::Continue;
                    }
                }
            }
        }

        // ── Actualizar Deck B ──
        {
            let (pos, dur, bus_opt, has_pipeline) = if let Some(ref p) = st.pipeline_b {
                (pipeline_position(p), pipeline_duration(p).max(1.0), p.bus(), true)
            } else {
                if st.stopped_after_b {
                    title_b_c.set_text(i18n::tr("now.stopped_after"));
                } else {
                    title_b_c.set_text(i18n::tr("now.no_track"));
                }
                if last_tooltip_b.is_some() {
                    last_tooltip_b = None;
                    title_b_c.set_tooltip_text(None::<&str>);
                }
                time_b_c.set_text("00:00");
                total_b_c.set_text("00:00");
                adj_b_c.set_upper(100.0);
                adj_b_c.set_value(0.0);
                (0.0, 0.0, None, false)
            };
            if has_pipeline {
                time_b_c.set_text(&secs_to_display(pos));
                total_b_c.set_text(&secs_to_display(dur));
                adj_b_c.set_upper(dur);
                adj_b_c.set_value(pos);
            }
            // ── Detector de silencio / posición estancada — Deck B ───────────────
            if has_pipeline
                && st.active_deck == ActiveDeck::B
                && st.deck_b_state == PlaybackState::Playing
                && !st.commercials_playing
                && !st.eventos_playing
                && !st.crossfade_triggered
                && st.silence_secs > 0.0
            {
                // ── Vía 1: nivel de audio bajo -45 dB (solo si pos es válida) ──
                if pos > 2.0 {
                    let level_stale = st.vu_last_level_msg.elapsed().as_secs_f64() > 1.5;
                    let is_silent = level_stale
                        || (st.vu_level_l_raw < -45.0 && st.vu_level_r_raw < -45.0);
                    if is_silent {
                        if st.silence_since_b.is_none() {
                            st.silence_since_b = Some(std::time::Instant::now());
                        } else if st.silence_since_b.unwrap().elapsed().as_secs_f64() >= st.silence_secs {
                            warn!("Deck B: silencio sostenido {:.0}s — {}", st.silence_secs,
                                  if st.loop_deck_b { "reiniciando (loop)" } else if st.stop_after_track { "deteniendo" } else { "avanzando" });
                            st.silence_since_b = None; st.stuck_since_b = None;
                            if st.loop_deck_b {
                                if let Some(ref p) = st.pipeline_b {
                                    let _ = p.set_state(gst::State::Ready);
                                    let _ = p.set_state(gst::State::Playing);
                                }
                                st.current_track_start = std::time::Instant::now();
                            } else if st.stop_after_track {
                                st.stop_after_track = false;
                                st.stopped_after_b = true;
                                action_stop(&mut st, ActiveDeck::B);
                                now_playing_c.set_text(i18n::tr("now.stopped_end"));
                                return glib::ControlFlow::Continue;
                            } else {
                                st.crossfade_triggered = true;
                                let next = st.current_index % st.playlist.len().max(1);
                                drop(st);
                                start_next_track(s.clone(), next);
                                return glib::ControlFlow::Continue;
                            }
                        }
                    } else {
                        st.silence_since_b = None;
                    }
                }
                // ── Vía 2: reloj de pared vs posición GStreamer ──
                // Guard pos > 0: igual que Deck A, evita falso positivo al despertar pantalla.
                let wall = st.current_track_start.elapsed().as_secs_f64();
                let drift = wall - pos.max(0.0);
                if pos > 0.0 && wall > 8.0 && drift >= st.silence_secs {
                    warn!("Deck B: reloj={:.1}s pos={:.1}s drift={:.1}s — {}",
                          wall, pos, drift,
                          if st.loop_deck_b { "reiniciando (loop)" } else if st.stop_after_track { "deteniendo" } else { "avanzando" });
                    st.stuck_since_b = None; st.silence_since_b = None;
                    if st.loop_deck_b {
                        if let Some(ref p) = st.pipeline_b {
                            let _ = p.set_state(gst::State::Ready);
                            let _ = p.set_state(gst::State::Playing);
                        }
                        st.current_track_start = std::time::Instant::now();
                    } else if st.stop_after_track {
                        st.stop_after_track = false;
                        st.stopped_after_b = true;
                        action_stop(&mut st, ActiveDeck::B);
                        now_playing_c.set_text(i18n::tr("now.stopped_end"));
                        return glib::ControlFlow::Continue;
                    } else {
                        st.crossfade_triggered = true;
                        let next = st.current_index % st.playlist.len().max(1);
                        drop(st);
                        start_next_track(s.clone(), next);
                        return glib::ControlFlow::Continue;
                    }
                }
            } else {
                st.silence_since_b = None;
                st.stuck_since_b = None;
                st.stuck_pos_b = -1.0;
            }
            if let Some(bus) = bus_opt {
                // Cruzar por posición: lanzar siguiente deck cuando quedan crossfade_secs.
                // Guardia de 500ms igual que deck A.
                if has_pipeline
                    && st.active_deck == ActiveDeck::B
                    && !st.crossfade_triggered
                    && !st.commercials_playing
                    && !st.eventos_playing
                    && !st.stop_after_track
                    && dur > st.crossfade_secs.max(1.0)
                    && pos > 0.0
                    && st.current_track_start.elapsed().as_secs_f64() > 0.5
                    && (dur - pos) <= st.crossfade_secs.max(0.1)
                {
                    if !st.loop_deck_b {
                        let next = st.current_index % st.playlist.len().max(1);
                        let next_path = st.playlist.get(next).map(|t| t.path.clone()).unwrap_or_default();
                        let sin_fundir = fundido_omitido(&read_gradio_config(), &st.current_track_path, &next_path);
                        if !sin_fundir {
                            st.crossfade_triggered = true;
                            drop(st);
                            start_next_track(s.clone(), next);
                            return glib::ControlFlow::Continue;
                        }
                        // sin_fundir: carpeta excluida de crossfade — dejar llegar al
                        // EOS natural para no cortar el final del audio, el adelanto
                        // por posición sólo tiene sentido si se va a cruzar.
                    }
                    // loop_deck_b activo: dejar llegar al EOS natural, no hacer crossfade
                }
                // EOS o Error: avanzar al siguiente track
                let mut advance_deck_b = false;
                while let Some(msg) = bus.pop() {
                    use gst::MessageView;
                    match msg.view() {
                        MessageView::Eos(_) => {
                            if st.active_deck == ActiveDeck::B { advance_deck_b = true; }
                        }
                        MessageView::Error(e) => {
                            if st.active_deck == ActiveDeck::B {
                                warn!("Pipeline B error: {} — avanzando al siguiente", e.error());
                                advance_deck_b = true;
                                // Liberar pipeline para evitar errores repetidos en bus
                                if let Some(p) = st.pipeline_b.take() {
                                    shutdown_pipeline(p);
                                }
                                break;
                            }
                        }
                        _ => {}
                    }
                }
                if advance_deck_b {
                    if st.loop_deck_b {
                        // Loop: llevar a Ready reinicia todos los elementos (EOS queda limpio),
                        // luego Playing vuelve a reproducir desde el inicio.
                        if let Some(ref p) = st.pipeline_b {
                            let _ = p.set_state(gst::State::Ready);
                            let _ = p.set_state(gst::State::Playing);
                        }
                    } else if st.stop_after_track {
                        st.stop_after_track = false;
                        st.stopped_after_b = true;
                        action_stop(&mut st, ActiveDeck::B);
                        now_playing_c.set_text(i18n::tr("now.stopped_end"));
                        st.current_title = String::new();
                    } else if !st.crossfade_triggered {
                        st.crossfade_triggered = true;
                        let next = st.current_index % st.playlist.len().max(1);
                        drop(st);
                        start_next_track(s.clone(), next);
                        return glib::ControlFlow::Continue;
                    }
                }
            }
        }

        // Actualizar títulos — pipeline_comm tiene prioridad (ya se actualizó arriba).
        // Solo actualizamos con current_title si NO hay pipeline_comm activo.
        if st.pipeline_comm.is_none() {
            let has_music = st.pipeline_a.is_some() || st.pipeline_b.is_some();
            if has_music {
                let title = if !st.current_title.is_empty() {
                    st.current_title.clone()
                } else if let Some(t) = st.playlist.get(st.current_index) {
                    t.title.clone()
                } else {
                    String::new()
                };
                if !title.is_empty() {
                    now_playing_c.set_text(&format!("▶  {}", title));
                    let path_tip = st.current_track_path.to_str().unwrap_or("").to_string();
                    let tip_val = if path_tip.is_empty() { None } else { Some(path_tip) };
                    if last_tooltip_now != tip_val {
                        now_playing_c.set_tooltip_text(tip_val.as_deref());
                        last_tooltip_now = tip_val.clone();
                    }
                    match st.active_deck {
                        ActiveDeck::A => {
                            title_a_c.set_text(&title);
                            if last_tooltip_a != tip_val {
                                title_a_c.set_tooltip_text(tip_val.as_deref());
                                last_tooltip_a = tip_val;
                            }
                        }
                        ActiveDeck::B => {
                            title_b_c.set_text(&title);
                            if last_tooltip_b != tip_val {
                                title_b_c.set_tooltip_text(tip_val.as_deref());
                                last_tooltip_b = tip_val;
                            }
                        }
                    }
                    status_c.set_text(&format!("▶ {} temas en cola", st.playlist.len()));
                    lbl_station_c.set_text(&st.station_name);
                }
            } else {
                // Sin música ni comerciales: limpiar etiquetas
                if st.stopped_after_a || st.stopped_after_b {
                    now_playing_c.set_text(i18n::tr("now.stopped_end"));
                } else {
                    now_playing_c.set_text(i18n::tr("now.app_name"));
                }
                if last_tooltip_now.is_some() {
                    last_tooltip_now = None;
                    now_playing_c.set_tooltip_text(None::<&str>);
                }
                if !st.stopped_after_a { title_a_c.set_text(i18n::tr("now.no_track")); }
                if !st.stopped_after_b { title_b_c.set_text(i18n::tr("now.no_track")); }
                st.current_title = String::new();
                status_c.set_text(&format!("{} temas en cola", st.playlist.len()));
                lbl_station_c.set_text(&st.station_name);
            }
        }

        // ── Actualizar CurrentSong.txt ────────────────────────────────────────
        {
            let song = if st.pipeline_comm.is_some() && !st.comm_title.is_empty() {
                st.comm_title.clone()
            } else if !st.current_title.is_empty() {
                st.current_title.clone()
            } else {
                String::new()
            };
            if !song.is_empty() && song != last_current_song {
                last_current_song = song.clone();
                let _ = fs::write(home_c.join("CurrentSong.txt"), &song);
            }
        }

        // ── Actualizar panel de playlist ──
        // Sincronizar con archivo si un módulo externo lo modificó (gr-client,
        // APK, playlist-refill). Se compara mtime para detectar también
        // reordenamientos remotos que no cambian el número de líneas.
        {
            let playlist_path = home_c.join(".gradio/data/tmp/playlist4");
            if let Ok(mtime) = fs::metadata(&playlist_path).and_then(|m| m.modified()) {
                if mtime != last_playlist_mtime {
                    last_playlist_mtime = mtime;
                    let new_tracks = load_playlist(&playlist_path);
                    st.playlist = new_tracks;
                    st.playlist_version = st.playlist_version.wrapping_add(1);
                }
            }
        }

        // Tomar snapshot ANTES de redibujar (sin soltar st)
        // Reconstruir sólo si el contenido visible cambió: comparamos un
        // signature (path + duración por track) en vez del version_now —
        // playlist-refill reescribe playlist4 frecuentemente sin alterar el
        // contenido, y rebuildear destruía los tooltips de los botones inline
        // antes de que GTK los pudiera mostrar (~500ms de hover).
        let n_filas = st.playlist.len() as u32;
        let current_rows = playlist_listbox_c.observe_children().n_items();
        let _version_now = st.playlist_version;
        last_playlist_version = _version_now;
        let signature_now: String = st.playlist.iter()
            .map(|t| format!("{}|{}", t.path.to_string_lossy(), t.duration_secs))
            .collect::<Vec<_>>()
            .join("\x1f");
        let needs_rebuild = current_rows != n_filas || signature_now != last_playlist_signature;
        let tracks_snap: Vec<(usize, String, String, f64)> = if needs_rebuild {
            last_playlist_signature = signature_now;
            st.playlist.iter().enumerate()
                .map(|(i, t)| (i, t.title.clone(), t.path.to_string_lossy().to_string(), t.duration_secs))
                .collect()
        } else {
            Vec::new()
        };
        let total_secs: f64 = st.playlist.iter().map(|t| t.duration_secs).sum();
        drop(st); // liberar lock: los widgets se crean sin el Mutex tomado

        if needs_rebuild {
            while let Some(child) = playlist_listbox_c.first_child() {
                playlist_listbox_c.remove(&child);
            }
            lbl_total_dur_c.set_text(&format!("Total: {}", secs_to_display(total_secs)));

            for (idx, title, path_str, dur_secs) in &tracks_snap {
                let idx = *idx;

                let row_box = GtkBox::new(Orientation::Horizontal, 4);

                // Botón numérico azul: click simple reproduce con crossfade
                let idx_btn = Button::with_label(&format!("{}", idx + 1));
                idx_btn.add_css_class("queue-idx-btn");
                {
                    let s_btn = s.clone();
                    idx_btn.connect_clicked(move |_| {
                        let target = {
                            let st = s_btn.lock().unwrap();
                            match st.active_deck {
                                ActiveDeck::A => ActiveDeck::B,
                                ActiveDeck::B => ActiveDeck::A,
                            }
                        };
                        play_track_to_deck(s_btn.clone(), idx, target);
                    });
                }

                let title_lbl = Label::new(Some(title.as_str()));
                title_lbl.set_halign(gtk4::Align::Start);
                title_lbl.set_hexpand(true);
                title_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End);
                // Tooltip con la ruta completa al pasar el mouse
                title_lbl.set_tooltip_text(Some(path_str.as_str()));
                row_box.append(&idx_btn);
                row_box.append(&title_lbl);

                // ── Duración del tema ────────────────────────────────────────
                let dur_lbl = Label::new(Some(&secs_to_display(*dur_secs)));
                dur_lbl.add_css_class("queue-dur-label");
                dur_lbl.set_halign(gtk4::Align::End);
                row_box.append(&dur_lbl);

                // ── Botones inline: subir / bajar / eliminar ─────────────────
                let n_tracks = tracks_snap.len();

                let btn_qi_up   = icon_button(&icon_queue_up(),    "Subir en la cola",      28);
                let btn_qi_down = icon_button(&icon_queue_down(),  "Bajar en la cola",      28);
                let btn_qi_del  = icon_button(&icon_queue_trash(), "Eliminar de la cola",   28);
                btn_qi_up.add_css_class("queue-action-btn");
                btn_qi_down.add_css_class("queue-action-btn");
                btn_qi_del.add_css_class("queue-action-btn");
                btn_qi_up.set_sensitive(idx > 0);
                btn_qi_down.set_sensitive(idx + 1 < n_tracks);

                {
                    let s2 = s.clone();
                    let pp = home_c.join(".gradio/data/tmp/playlist4");
                    btn_qi_up.connect_clicked(move |_| {
                        let mut st = s2.lock().unwrap();
                        if idx > 0 && idx < st.playlist.len() {
                            st.playlist.swap(idx, idx - 1);
                            save_playlist_to_file(&st.playlist, &pp);
                            st.playlist_version = st.playlist_version.wrapping_add(1);
                        }
                    });
                }
                {
                    let s2 = s.clone();
                    let pp = home_c.join(".gradio/data/tmp/playlist4");
                    btn_qi_down.connect_clicked(move |_| {
                        let mut st = s2.lock().unwrap();
                        if idx + 1 < st.playlist.len() {
                            st.playlist.swap(idx, idx + 1);
                            save_playlist_to_file(&st.playlist, &pp);
                            st.playlist_version = st.playlist_version.wrapping_add(1);
                        }
                    });
                }
                {
                    let s2 = s.clone();
                    let pp = home_c.join(".gradio/data/tmp/playlist4");
                    btn_qi_del.connect_clicked(move |_| {
                        let mut st = s2.lock().unwrap();
                        if idx < st.playlist.len() {
                            st.playlist.remove(idx);
                            save_playlist_to_file(&st.playlist, &pp);
                            st.playlist_version = st.playlist_version.wrapping_add(1);
                        }
                    });
                }

                row_box.append(&btn_qi_up);
                row_box.append(&btn_qi_down);
                row_box.append(&btn_qi_del);

                let row = ListBoxRow::new();
                row.set_child(Some(&row_box));
                if idx == 0 {
                    row.add_css_class("playing-row");
                }

                // ── DragSource: arrastrar para reordenar O soltar sobre un deck ──
                {
                    let drag_src = DragSource::new();
                    drag_src.set_actions(gdk4::DragAction::MOVE);
                    let payload = format!("playlist-row:{}", idx);
                    drag_src.connect_prepare(move |_src, _x, _y| {
                        Some(ContentProvider::for_value(&payload.clone().to_value()))
                    });
                    row.add_controller(drag_src);
                }

                // ── Botón derecho: ventana flotante de menú contextual ──
                {
                    let s_hora   = s.clone();
                    let s_stream = s.clone();
                    let pp_hora   = home_c.join(".gradio/data/tmp/playlist4");
                    let pp_stream = home_c.join(".gradio/data/tmp/playlist4");
                    let win_ref  = win_c.clone();

                    // Popover posicionado sobre el cursor — anclado al row_box
                    let popover = gtk4::Popover::new();
                    popover.set_parent(&row_box);
                    popover.set_has_arrow(false);
                    popover.set_autohide(true);

                    let vbox = GtkBox::new(Orientation::Vertical, 0);
                    vbox.add_css_class("context-menu-box");
                    vbox.set_margin_top(2);
                    vbox.set_margin_bottom(2);
                    vbox.set_margin_start(2);
                    vbox.set_margin_end(2);

                    let btn_cue       = Button::with_label(i18n::tr("ctx.cue"));
                    let btn_hora      = Button::with_label(i18n::tr("ctx.ins_hora"));
                    let btn_stream    = Button::with_label(i18n::tr("ctx.ins_stream"));
                    let btn_load_list = Button::with_label(i18n::tr("ctx.load_list"));
                    let btn_regen     = Button::with_label(i18n::tr("ctx.regen"));
                    let btn_del       = Button::with_label(i18n::tr("ctx.del_cola"));
                    btn_cue.add_css_class("context-menu-item");
                    btn_hora.add_css_class("context-menu-item");
                    btn_stream.add_css_class("context-menu-item");
                    btn_load_list.add_css_class("context-menu-item");
                    btn_regen.add_css_class("context-menu-item");
                    btn_del.add_css_class("context-menu-item");
                    btn_del.add_css_class("context-menu-item-danger");

                    vbox.append(&btn_cue);
                    vbox.append(&btn_hora);
                    vbox.append(&btn_stream);
                    vbox.append(&btn_load_list);
                    vbox.append(&btn_regen);
                    vbox.append(&btn_del);
                    popover.set_child(Some(&vbox));

                    // Acción CUE: preescuchar por tarjeta secundaria
                    let pop_cue        = popover.clone();
                    let s_cue          = s.clone();
                    let win_cue        = win_c.clone();
                    let track_path_cue = PathBuf::from(path_str.clone());
                    let title_cue      = title.clone();
                    btn_cue.connect_clicked(move |_| {
                        pop_cue.popdown();
                        let uri = {
                            let p = track_path_cue.to_string_lossy();
                            if p.starts_with("http://") || p.starts_with("https://") {
                                p.into_owned()
                            } else {
                                format!("file://{}", p)
                            }
                        };
                        if uri == "file://" || uri.is_empty() { return; }

                        // Detener CUE anterior si hay uno
                        let old_cue = s_cue.lock().unwrap().pipeline_cue.take();
                        if let Some(p) = old_cue { shutdown_pipeline(p); }

                        // Lanzar nuevo CUE
                        match build_pipeline_cue(&uri) {
                            Ok(pipeline) => {
                                let _ = pipeline.set_state(gst::State::Playing);
                                s_cue.lock().unwrap().pipeline_cue = Some(pipeline.clone());
                                info!("CUE: reproduciendo {}", uri);

                                // ── Ventana flotante de control CUE ──
                                let cue_win = gtk4::Window::new();
                                cue_win.set_title(Some(&i18n::tr("cue.win_title").replace("{title}", &title_cue)));
                                cue_win.set_transient_for(Some(win_cue.upcast_ref::<gtk4::Window>()));
                                cue_win.set_default_size(340, 90);
                                cue_win.set_resizable(false);
                                cue_win.set_decorated(true);

                                let vbox = GtkBox::new(Orientation::Vertical, 8);
                                vbox.set_margin_top(12);
                                vbox.set_margin_bottom(12);
                                vbox.set_margin_start(12);
                                vbox.set_margin_end(12);

                                // Barra de progreso + tiempo
                                let seek = gtk4::Scale::new(
                                    Orientation::Horizontal,
                                    Some(&gtk4::Adjustment::new(0.0, 0.0, 100.0, 1.0, 5.0, 0.0))
                                );
                                seek.set_hexpand(true);
                                seek.set_draw_value(false);

                                let time_lbl = Label::new(Some("00:00 / 00:00"));
                                time_lbl.add_css_class("time-label");

                                let time_row = GtkBox::new(Orientation::Horizontal, 8);
                                time_row.append(&seek);
                                time_row.append(&time_lbl);

                                // Botones
                                let btn_row = GtkBox::new(Orientation::Horizontal, 8);
                                btn_row.set_halign(gtk4::Align::Center);
                                let btn_pause_cue = Button::with_label(i18n::tr("cue.btn_pause"));
                                let btn_stop_cue  = Button::with_label(i18n::tr("cue.btn_stop"));
                                btn_pause_cue.add_css_class("control-btn");
                                btn_stop_cue.add_css_class("control-btn");
                                btn_row.append(&btn_pause_cue);
                                btn_row.append(&btn_stop_cue);

                                vbox.append(&time_row);
                                vbox.append(&btn_row);
                                cue_win.set_child(Some(&vbox));

                                // Pausa / Resume
                                let pipe_pause = pipeline.clone();
                                btn_pause_cue.connect_clicked(move |btn| {
                                    match pipe_pause.current_state() {
                                        gst::State::Playing => {
                                            let _ = pipe_pause.set_state(gst::State::Paused);
                                            btn.set_label(i18n::tr("cue.btn_resume"));
                                        }
                                        _ => {
                                            let _ = pipe_pause.set_state(gst::State::Playing);
                                            btn.set_label(i18n::tr("cue.btn_pause"));
                                        }
                                    }
                                });

                                // Stop
                                let pipe_stop  = pipeline.clone();
                                let s_stop     = s_cue.clone();
                                let win_stop   = cue_win.clone();
                                btn_stop_cue.connect_clicked(move |_| {
                                    let taken = s_stop.lock().unwrap().pipeline_cue.take();
                                    if let Some(p) = taken { shutdown_pipeline(p); }
                                    else { shutdown_pipeline(pipe_stop.clone()); }
                                    win_stop.close();
                                });

                                // Cerrar ventana = detener CUE
                                let pipe_close = pipeline.clone();
                                let s_close    = s_cue.clone();
                                cue_win.connect_close_request(move |_| {
                                    let taken = s_close.lock().unwrap().pipeline_cue.take();
                                    if let Some(p) = taken { shutdown_pipeline(p); }
                                    else { shutdown_pipeline(pipe_close.clone()); }
                                    glib::Propagation::Proceed
                                });

                                // Timer: actualizar seek + tiempo + cerrar al EOS
                                let pipe_timer  = pipeline.clone();
                                let seek_c      = seek.clone();
                                let time_c      = time_lbl.clone();
                                let win_timer   = cue_win.clone();
                                let s_timer     = s_cue.clone();
                                timeout_add_local(Duration::from_millis(300), move || {
                                    let pos = pipeline_position(&pipe_timer);
                                    let dur = pipeline_duration(&pipe_timer).max(1.0);
                                    seek_c.set_range(0.0, dur);
                                    seek_c.set_value(pos);
                                    time_c.set_text(&format!("{} / {}",
                                        secs_to_display(pos), secs_to_display(dur)));
                                    // Detectar EOS
                                    if let Some(bus) = pipe_timer.bus() {
                                        while let Some(msg) = bus.pop() {
                                            if let gst::MessageView::Eos(_) = msg.view() {
                                                let taken = s_timer.lock().unwrap().pipeline_cue.take();
                                                if let Some(p) = taken { shutdown_pipeline(p); }
                                                win_timer.close();
                                                return glib::ControlFlow::Break;
                                            }
                                        }
                                    }
                                    // Detener timer si la ventana ya se cerró
                                    if !win_timer.is_visible() {
                                        return glib::ControlFlow::Break;
                                    }
                                    glib::ControlFlow::Continue
                                });

                                // Seek manual
                                let pipe_seek = pipeline.clone();
                                seek.connect_change_value(move |_, _, value| {
                                    pipeline_seek(&pipe_seek, value);
                                    glib::Propagation::Proceed
                                });

                                cue_win.present();
                            }
                            Err(e) => error!("CUE: error al crear pipeline: {}", e),
                        }
                    });

                    // Acción Hora
                    let pop1 = popover.clone();
                    let s_h  = s_hora.clone();
                    let pp_h = pp_hora.clone();
                    btn_hora.connect_clicked(move |_| {
                        pop1.popdown();
                        let hora_track = Track {
                            path: PathBuf::from("Hora"),
                            duration_secs: 0.0,
                            title: "🕐 Hora".to_string(),
                        };
                        let mut st = s_h.lock().unwrap();
                        st.playlist.insert(idx, hora_track);
                        save_playlist_to_file(&st.playlist, &pp_h);
                        st.playlist_version = st.playlist_version.wrapping_add(1);
                    });

                    // Acción Streaming
                    let pop2   = popover.clone();
                    let s_s    = s_stream.clone();
                    let pp_s   = pp_stream.clone();
                    let win_r2 = win_ref.clone();
                    btn_stream.connect_clicked(move |_| {
                        pop2.popdown();
                        show_insert_stream_dialog(&win_r2, s_s.clone(), idx, pp_s.clone());
                    });

                    // Acción Cargar lista .gradio en posición idx
                    {
                        let pop_ll  = popover.clone();
                        let s_ll    = s.clone();
                        let win_ll  = win_c.clone();
                        btn_load_list.connect_clicked(move |_| {
                            pop_ll.popdown();
                            let fc = gtk4::FileChooserDialog::new(
                                Some("Cargar lista .gradio"),
                                Some(win_ll.upcast_ref::<gtk4::Window>()),
                                gtk4::FileChooserAction::Open,
                                &[("Cancelar", gtk4::ResponseType::Cancel),
                                  ("Insertar", gtk4::ResponseType::Accept)],
                            );
                            fc.set_modal(true);
                            let filter = gtk4::FileFilter::new();
                            filter.set_name(Some("Listas G Radio (*.gradio)"));
                            filter.add_pattern("*.gradio");
                            fc.add_filter(&filter);
                            let s_fc = s_ll.clone();
                            fc.connect_response(move |fc, resp| {
                                if resp == gtk4::ResponseType::Accept {
                                    if let Some(file) = fc.file() {
                                        if let Some(path) = file.path() {
                                            apply_gradio_file(&s_fc, &path, Some(idx));
                                        }
                                    }
                                }
                                fc.close();
                            });
                            fc.present();
                        });
                    }

                    // Acción Regenerar lista (vaciar y dejar que gr-parrilla la rellene)
                    {
                        let pop_rg = popover.clone();
                        let s_rg   = s.clone();
                        let pp_rg  = pp_hora.clone();
                        btn_regen.connect_clicked(move |_| {
                            pop_rg.popdown();
                            let mut st = s_rg.lock().unwrap();
                            st.playlist.clear();
                            save_playlist_to_file(&st.playlist, &pp_rg);
                            st.playlist_version = st.playlist_version.wrapping_add(1);
                            info!("Playlist vaciada para regeneración por gr-parrilla");
                        });
                    }

                    // Acción Eliminar
                    let pop3 = popover.clone();
                    let s_d  = s.clone();
                    let pp_d = pp_hora.clone(); // misma ruta playlist4
                    btn_del.connect_clicked(move |_| {
                        pop3.popdown();
                        let mut st = s_d.lock().unwrap();
                        if idx < st.playlist.len() {
                            st.playlist.remove(idx);
                            save_playlist_to_file(&st.playlist, &pp_d);
                            st.playlist_version = st.playlist_version.wrapping_add(1);
                        }
                    });

                    // Botón derecho: posicionar popover en el cursor y abrir
                    let pop_ref = popover.clone();
                    let right_click = GestureClick::new();
                    right_click.set_button(3);
                    right_click.connect_released(move |gesture, _n, x, y| {
                        gesture.set_state(gtk4::EventSequenceState::Claimed);
                        pop_ref.set_pointing_to(Some(&gdk4::Rectangle::new(
                            x as i32, y as i32, 1, 1,
                        )));
                        pop_ref.popup();
                    });
                    row_box.add_controller(right_click);
                }

                playlist_listbox_c.append(&row);
            }
        }

        // ── Helper: actualizar un listbox de cola desde un archivo ──
        // Reconstruye si cambió la "firma" del archivo (líneas concatenadas).
        // Cada fila ahora muestra título + duración + botones up/down.
        let mut update_queue = |listbox: &ListBox,
                                file_path: &std::path::Path,
                                last_sig: &mut String| {
            let lines: Vec<String> = fs::read_to_string(file_path)
                .unwrap_or_default()
                .lines()
                .filter(|l| !l.trim().is_empty())
                .map(String::from)
                .collect();

            let signature: String = lines.join("\n");
            if signature == *last_sig { return; }
            *last_sig = signature;

            while let Some(child) = listbox.first_child() {
                listbox.remove(&child);
            }
            let n = lines.len();
            for (i, line) in lines.iter().enumerate() {
                let row_box = GtkBox::new(Orientation::Horizontal, 4);

                let title_text = display_name_for_queue_line(line);
                let title_lbl = Label::new(Some(&title_text));
                title_lbl.set_halign(gtk4::Align::Start);
                title_lbl.set_hexpand(true);
                title_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End);
                title_lbl.set_tooltip_text(Some(line));

                let dur = duration_for_queue_line(line);
                let dur_str = if dur > 0.0 { secs_to_display(dur) } else { String::from("—") };
                let dur_lbl = Label::new(Some(&dur_str));
                dur_lbl.add_css_class("queue-dur-label");
                dur_lbl.set_halign(gtk4::Align::End);

                let btn_up   = icon_button(&icon_queue_up(),   "Subir en la cola", 24);
                let btn_down = icon_button(&icon_queue_down(), "Bajar en la cola", 24);
                btn_up.add_css_class("queue-action-btn");
                btn_down.add_css_class("queue-action-btn");
                btn_up.set_sensitive(i > 0);
                btn_down.set_sensitive(i + 1 < n);
                {
                    let fp = file_path.to_path_buf();
                    btn_up.connect_clicked(move |_| {
                        if i > 0 { swap_lines_in_file(&fp, i, i - 1); }
                    });
                }
                {
                    let fp = file_path.to_path_buf();
                    btn_down.connect_clicked(move |_| {
                        if i + 1 < n { swap_lines_in_file(&fp, i, i + 1); }
                    });
                }

                row_box.append(&title_lbl);
                row_box.append(&dur_lbl);
                row_box.append(&btn_up);
                row_box.append(&btn_down);

                let row = ListBoxRow::new();
                row.set_child(Some(&row_box));
                if i == 0 { row.add_css_class("playing-row"); }
                listbox.append(&row);
            }
        };

        // ── Actualizar paneles de cola (espera y eventos) ──
        update_queue(&espera_listbox_c,
            &home_c.join(".gradio/data/tmp/eventos-esperalist"),
            &mut last_signature_espera);
        update_queue(&eventos_listbox_c,
            &home_c.join(".gradio/data/tmp/eventoslist"),
            &mut last_signature_evt);

        // ── Visibilidad dinámica del bloque de eventos ─────────────────────────
        {
            let hay_espera = !fs::read_to_string(&home_c.join(".gradio/data/tmp/eventos-esperalist"))
                .unwrap_or_default().trim().is_empty();
            let hay_eventos = !fs::read_to_string(&home_c.join(".gradio/data/tmp/eventoslist"))
                .unwrap_or_default().trim().is_empty();
            let mostrar = hay_espera || hay_eventos;
            if paned_eventos_c.is_visible() != mostrar {
                paned_eventos_c.set_visible(mostrar);
            }
        }

        // ── Actualizar próximas tandas de comerciales ──────────────────────────
        // Watcher de comercialeslist4: refresca la cola activa en < 200ms
        {
            let sig = fs::read_to_string(&home_c.join(".gradio/data/tmp/comercialeslist4"))
                .unwrap_or_default()
                .lines()
                .take(30)
                .collect::<Vec<_>>()
                .join("\n");
            if sig != last_sig_comlist {
                last_sig_comlist = sig;
                rebuild_tandas_box(&tandas_box_c, &home_c, s.clone());
                upcoming_refresh_counter = 0; // evitar doble rebuild
            }
        }

        upcoming_refresh_counter += 1;
        if upcoming_refresh_counter >= 150 { // cada 30 segundos (150 × 200ms)
            upcoming_refresh_counter = 0;
            rebuild_tandas_box(&tandas_box_c, &home_c, s.clone());
        }

        glib::ControlFlow::Continue
    });

    window.set_child(Some(&root));

    // Ícono de G Radio en el taskbar/titlebar — usar connect_realize igual que el resto de apps
    {
        let loader = gdk4::gdk_pixbuf::PixbufLoader::new();
        let _ = loader.write(&icon_gradio());
        let _ = loader.close();
        if let Some(pb) = loader.pixbuf() {
            let pb48 = pb.scale_simple(48, 48, gdk4::gdk_pixbuf::InterpType::Bilinear)
                .unwrap_or(pb);
            let tex = gdk4::Texture::for_pixbuf(&pb48);
            let window2 = window.clone();
            window.connect_realize(move |w| {
                if let Some(surf) = w.surface() {
                    use gdk4::prelude::ToplevelExt;
                    if let Some(tl) = surf.dynamic_cast_ref::<gdk4::Toplevel>() {
                        tl.set_icon_list(&[tex.clone()]);
                    }
                    let display = gtk4::prelude::WidgetExt::display(w);
                    use gdk4::prelude::DisplayExt;
                    if let Some(monitor) = display.monitor_at_surface(&surf) {
                        use gdk4::prelude::MonitorExt;
                        let geo = monitor.geometry();
                        let ancho = (geo.width() - 20).min(1100).max(700);
                        let alto  = (geo.height() - 90).min(680).max(400);
                        window2.set_default_size(ancho, alto);
                    }
                }
            });
        }
    }

    // ── Limpieza al cerrar la ventana ──
    // Sin esto los pipelines en PLAYING se descartan sin pasar por NULL → CRITICAL de GStreamer
    {
        let state_close = state.clone();
        window.connect_close_request(move |_| {
            let mut st = state_close.lock().unwrap();
            if let Some(p) = st.pipeline_a.take()         { shutdown_pipeline(p); }
            if let Some(p) = st.pipeline_b.take()         { shutdown_pipeline(p); }
            if let Some(p) = st.pipeline_comm.take()      { shutdown_pipeline(p); }
            if let Some(p) = st.pipeline_cue.take()       { shutdown_pipeline(p); }
            if let Some(p) = st.pipeline_botonera.take()  { shutdown_pipeline(p); }
            glib::Propagation::Proceed
        });
    }

    window.present();

    // ── Autoplay: iniciar reproducción al arrancar ──
    {
        let state_ap = state.clone();
        let started_at = std::time::Instant::now();
        // En Windows el registro de plugins GST se construye en el primer
        // arranque (portable) y puede tardar ~2s antes de crear el primer
        // pipeline; en Linux el registro del sistema ya está listo, sin margen.
        #[cfg(target_os = "windows")]
        let min_wait = Duration::from_secs(2);
        #[cfg(not(target_os = "windows"))]
        let min_wait = Duration::ZERO;

        // playlist-refill (proceso aparte) escribe playlist4 y el timer de
        // sincronización de la UI tarda hasta 200ms en recargarlo a
        // st.playlist. Antes se comprobaba una sola vez: si la cola seguía
        // vacía justo en ese instante el autoplay se perdía para siempre
        // aunque la parrilla existiera. Ahora se reintenta cada 200ms
        // hasta agotar el margen, en vez de comprobar una única vez.
        const AUTOPLAY_TIMEOUT: Duration = Duration::from_secs(20);
        timeout_add_local(Duration::from_millis(200), move || {
            if started_at.elapsed() < min_wait {
                return glib::ControlFlow::Continue;
            }
            let has_tracks = state_ap.lock().unwrap().playlist.len() > 0;
            if has_tracks {
                start_next_track(state_ap.clone(), 0);
                return glib::ControlFlow::Break;
            }
            if started_at.elapsed() >= min_wait + AUTOPLAY_TIMEOUT {
                warn!("Autoplay: sin tracks en playlist tras {}s, se omite arranque automático",
                      AUTOPLAY_TIMEOUT.as_secs());
                return glib::ControlFlow::Break;
            }
            glib::ControlFlow::Continue
        });
    }
}

// ── Menú contextual para vaciar cola ─────────────────────────────────────────

fn agregar_menu_vaciar(
    frame: &gtk4::Frame,
    nombre: &'static str,
    archivo: std::path::PathBuf,
    state: SharedState,
) {
    use gtk4::prelude::*;

    let gesture = gtk4::GestureClick::new();
    gesture.set_button(3); // botón derecho

    let frame_weak = frame.downgrade();
    gesture.connect_released(move |_g, _n, x, y| {
        let Some(frame) = frame_weak.upgrade() else { return };

        let popover = gtk4::Popover::new();
        popover.set_parent(&frame);
        popover.set_position(gtk4::PositionType::Bottom);
        let gdk_rect = gtk4::gdk::Rectangle::new(x as i32, y as i32, 1, 1);
        popover.set_pointing_to(Some(&gdk_rect));

        let vbox = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
        vbox.set_margin_top(4);
        vbox.set_margin_bottom(4);
        vbox.set_margin_start(4);
        vbox.set_margin_end(4);

        let lbl_titulo = gtk4::Label::new(Some(&format!("Cola: {}", nombre)));
        lbl_titulo.add_css_class("dim-label");
        lbl_titulo.set_margin_bottom(4);
        vbox.append(&lbl_titulo);

        let btn_vaciar = gtk4::Button::with_label(&format!("🗑 Vaciar {}", nombre));
        btn_vaciar.add_css_class("destructive-action");

        let archivo_v = archivo.clone();
        let state_v   = state.clone();
        let popover_weak = popover.downgrade();
        btn_vaciar.connect_clicked(move |_| {
            let _ = std::fs::write(&archivo_v, "");
            interrupt_pautaje(&state_v);
            info!("Vaciada cola: {}", archivo_v.display());
            if let Some(p) = popover_weak.upgrade() { p.popdown(); }
        });
        vbox.append(&btn_vaciar);

        popover.set_child(Some(&vbox));
        popover.popup();
    });

    frame.add_controller(gesture);
}
