2026-04-23 07:00:06 -03:00
|
|
|
fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
|
|
|
|
|
if samples.len() < 2 {
|
|
|
|
|
return 0.0;
|
|
|
|
|
}
|
|
|
|
|
let mut values = samples.iter().map(|(_, value)| *value).collect::<Vec<_>>();
|
|
|
|
|
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
|
let median = values[values.len() / 2];
|
|
|
|
|
let mut deviations = values
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|value| (value - median).abs())
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
deviations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
|
deviations[deviations.len() / 2]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
/// Apply a remote-audio gain slider update without unwinding through GTK callbacks.
|
|
|
|
|
fn apply_audio_gain_change(
|
|
|
|
|
scale: >k::Scale,
|
|
|
|
|
state: &Rc<RefCell<LauncherState>>,
|
|
|
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
|
|
|
child_proc: &Rc<RefCell<Option<RelayChild>>>,
|
|
|
|
|
) -> bool {
|
|
|
|
|
let percent = scale
|
|
|
|
|
.value()
|
|
|
|
|
.round()
|
|
|
|
|
.clamp(0.0, MAX_AUDIO_GAIN_PERCENT as f64) as u32;
|
|
|
|
|
let label = {
|
|
|
|
|
let Ok(mut state) = state.try_borrow_mut() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
if state.audio_gain_percent == percent {
|
|
|
|
|
widgets.audio_gain_value.set_text(&state.audio_gain_label());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
state.set_audio_gain_percent(percent);
|
|
|
|
|
state.audio_gain_label()
|
|
|
|
|
};
|
|
|
|
|
widgets.audio_gain_value.set_text(&label);
|
|
|
|
|
let relay_live = child_proc
|
|
|
|
|
.try_borrow()
|
|
|
|
|
.map(|child| child.is_some())
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if relay_live {
|
|
|
|
|
let path = audio_gain_control_path();
|
|
|
|
|
match write_audio_gain_request(&path, percent) {
|
|
|
|
|
Ok(()) => widgets
|
|
|
|
|
.status_label
|
|
|
|
|
.set_text(&format!("Remote audio gain set to {label}.")),
|
|
|
|
|
Err(err) => widgets.status_label.set_text(&format!(
|
|
|
|
|
"Remote audio gain set to {label} for the next relay launch, but live gain control could not be written: {err}"
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
widgets.status_label.set_text(&format!(
|
|
|
|
|
"Remote audio gain set to {label} for the next relay launch."
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
/// Apply a microphone uplink gain slider update without unwinding through GTK callbacks.
|
|
|
|
|
fn apply_mic_gain_change(
|
|
|
|
|
scale: >k::Scale,
|
|
|
|
|
state: &Rc<RefCell<LauncherState>>,
|
|
|
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
|
|
|
child_proc: &Rc<RefCell<Option<RelayChild>>>,
|
|
|
|
|
) -> bool {
|
|
|
|
|
let percent = scale
|
|
|
|
|
.value()
|
|
|
|
|
.round()
|
|
|
|
|
.clamp(0.0, MAX_MIC_GAIN_PERCENT as f64) as u32;
|
|
|
|
|
let label = {
|
|
|
|
|
let Ok(mut state) = state.try_borrow_mut() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
if state.mic_gain_percent == percent {
|
|
|
|
|
widgets.mic_gain_value.set_text(&state.mic_gain_label());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
state.set_mic_gain_percent(percent);
|
|
|
|
|
state.mic_gain_label()
|
|
|
|
|
};
|
|
|
|
|
widgets.mic_gain_value.set_text(&label);
|
|
|
|
|
let relay_live = child_proc
|
|
|
|
|
.try_borrow()
|
|
|
|
|
.map(|child| child.is_some())
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if relay_live {
|
|
|
|
|
let path = mic_gain_control_path();
|
|
|
|
|
match write_mic_gain_request(&path, percent) {
|
|
|
|
|
Ok(()) => widgets.status_label.set_text(&format!("Mic gain set to {label}.")),
|
|
|
|
|
Err(err) => widgets.status_label.set_text(&format!(
|
|
|
|
|
"Mic gain set to {label} for the next relay launch, but live gain control could not be written: {err}"
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
widgets.status_label.set_text(&format!(
|
|
|
|
|
"Mic gain set to {label} for the next relay launch."
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 15:04:00 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
/// Apply a live media soft-pause/resume request without restarting USB gadget functions.
|
|
|
|
|
fn apply_media_control_change(
|
|
|
|
|
state_snapshot: &LauncherState,
|
|
|
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
|
|
|
child_proc: &Rc<RefCell<Option<RelayChild>>>,
|
|
|
|
|
feed_label: &str,
|
|
|
|
|
enabled: bool,
|
|
|
|
|
) {
|
|
|
|
|
let relay_live = child_proc
|
|
|
|
|
.try_borrow()
|
|
|
|
|
.map(|child| child.is_some())
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
let action = if enabled { "resumed" } else { "soft-paused" };
|
|
|
|
|
if relay_live {
|
|
|
|
|
let path = media_control_path();
|
|
|
|
|
match write_media_control_request(&path, state_snapshot) {
|
|
|
|
|
Ok(()) => widgets
|
|
|
|
|
.status_label
|
|
|
|
|
.set_text(&format!("{feed_label} {action} for the live relay.")),
|
|
|
|
|
Err(err) => widgets.status_label.set_text(&format!(
|
|
|
|
|
"{feed_label} will be {action} on the next relay launch, but the live soft-pause control could not be written: {err}"
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
widgets.status_label.set_text(&format!(
|
|
|
|
|
"{feed_label} will start {} on the next relay launch.",
|
|
|
|
|
if enabled { "enabled" } else { "paused" }
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 07:00:06 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-30 08:16:57 -03:00
|
|
|
/// Refresh relay capture-power state in the background so GTK stays responsive.
|
2026-04-23 07:00:06 -03:00
|
|
|
fn request_capture_power_refresh(
|
|
|
|
|
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
|
|
|
|
server_addr: String,
|
|
|
|
|
delay: Duration,
|
|
|
|
|
) {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
if !delay.is_zero() {
|
|
|
|
|
std::thread::sleep(delay);
|
|
|
|
|
}
|
|
|
|
|
let result = fetch_capture_power(&server_addr).map_err(|err| err.to_string());
|
|
|
|
|
let _ = power_tx.send(PowerMessage::Refresh(result));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn request_capture_power_command(
|
|
|
|
|
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
|
|
|
|
server_addr: String,
|
|
|
|
|
command: CapturePowerCommand,
|
|
|
|
|
) {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let result = set_capture_power_mode(&server_addr, command).map_err(|err| err.to_string());
|
|
|
|
|
let _ = power_tx.send(PowerMessage::Command(result));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-30 08:16:57 -03:00
|
|
|
/// Refresh upstream calibration state in the background so the UI can poll safely.
|
|
|
|
|
fn request_calibration_refresh(
|
|
|
|
|
calibration_tx: std::sync::mpsc::Sender<CalibrationMessage>,
|
|
|
|
|
server_addr: String,
|
|
|
|
|
delay: Duration,
|
|
|
|
|
) {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
if !delay.is_zero() {
|
|
|
|
|
std::thread::sleep(delay);
|
|
|
|
|
}
|
|
|
|
|
let result = fetch_calibration(&server_addr).map_err(|err| err.to_string());
|
|
|
|
|
let _ = calibration_tx.send(CalibrationMessage::Refresh(result));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn request_calibration_command<F>(
|
|
|
|
|
calibration_tx: std::sync::mpsc::Sender<CalibrationMessage>,
|
|
|
|
|
server_addr: String,
|
|
|
|
|
action: F,
|
|
|
|
|
) where
|
|
|
|
|
F: FnOnce(&str) -> anyhow::Result<CalibrationStatus> + Send + 'static,
|
|
|
|
|
{
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let result = action(&server_addr).map_err(|err| err.to_string());
|
|
|
|
|
let _ = calibration_tx.send(CalibrationMessage::Command(result));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
/// Probe server capabilities on a short-lived runtime without blocking the UI thread.
|
2026-04-23 07:00:06 -03:00
|
|
|
fn request_handshake_caps(
|
|
|
|
|
caps_tx: std::sync::mpsc::Sender<CapsMessage>,
|
|
|
|
|
server_addr: String,
|
|
|
|
|
delay: Duration,
|
|
|
|
|
) {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
if !delay.is_zero() {
|
|
|
|
|
std::thread::sleep(delay);
|
|
|
|
|
}
|
|
|
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
|
|
|
.enable_all()
|
|
|
|
|
.build();
|
|
|
|
|
let probe = match runtime {
|
|
|
|
|
Ok(runtime) => runtime.block_on(probe(&server_addr)),
|
|
|
|
|
Err(_) => HandshakeProbe::default(),
|
|
|
|
|
};
|
|
|
|
|
let _ = caps_tx.send(CapsMessage::Refresh(probe));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn unavailable_capture_power(detail: String) -> CapturePowerStatus {
|
|
|
|
|
CapturePowerStatus {
|
|
|
|
|
available: false,
|
|
|
|
|
enabled: false,
|
|
|
|
|
unit: "relay.service".to_string(),
|
|
|
|
|
detail,
|
|
|
|
|
active_leases: 0,
|
|
|
|
|
mode: "auto".to_string(),
|
|
|
|
|
detected_devices: 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-30 08:16:57 -03:00
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn unavailable_calibration(detail: String) -> CalibrationStatus {
|
|
|
|
|
CalibrationStatus::unavailable(detail)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn calibration_summary(calibration: &CalibrationStatus) -> String {
|
|
|
|
|
format!(
|
|
|
|
|
"Upstream A/V calibration: {} audio {:+.1} ms, video {:+.1} ms ({}, {}).",
|
|
|
|
|
calibration.profile,
|
|
|
|
|
calibration.active_audio_offset_us as f64 / 1000.0,
|
|
|
|
|
calibration.active_video_offset_us as f64 / 1000.0,
|
|
|
|
|
calibration.source,
|
|
|
|
|
calibration.confidence
|
|
|
|
|
)
|
|
|
|
|
}
|