lesavka: refresh staged devices and steady diagnostics
This commit is contained in:
parent
c24bef1bf2
commit
ba443371e6
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.24"
|
||||
version = "0.11.25"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -13,7 +13,7 @@ use std::time::Duration;
|
||||
|
||||
const CAMERA_PREVIEW_WIDTH: i32 = 360;
|
||||
const CAMERA_PREVIEW_HEIGHT: i32 = 202;
|
||||
const CAMERA_PREVIEW_IDLE: &str = "Select a camera and click Start Preview.";
|
||||
const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview.";
|
||||
const MIC_MONITOR_RATE: i32 = 16_000;
|
||||
const MIC_MONITOR_CHANNELS: i32 = 1;
|
||||
const MIC_MONITOR_SAMPLE_BYTES: usize = 2;
|
||||
@ -345,7 +345,7 @@ impl LocalCameraPreview {
|
||||
|
||||
self.set_status(match self.selected_device.as_deref() {
|
||||
Some(camera) => format!(
|
||||
"Selected {camera}. Start Preview to confirm framing here before you launch the relay."
|
||||
"Selected {camera}. Start Preview to confirm webcam framing here before you launch the relay."
|
||||
),
|
||||
None => CAMERA_PREVIEW_IDLE.to_string(),
|
||||
});
|
||||
|
||||
@ -13,7 +13,7 @@ use {
|
||||
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
|
||||
FeedSourcePreset, InputRouting, LauncherState,
|
||||
},
|
||||
super::ui_components::build_launcher_view,
|
||||
super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo},
|
||||
super::ui_runtime::{
|
||||
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
||||
capture_swap_key, copy_session_log, dock_all_displays_to_preview, dock_display_to_preview,
|
||||
@ -125,6 +125,21 @@ impl NetworkTelemetry {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn retained_stage_selection(current: Option<&str>, values: &[String]) -> Option<String> {
|
||||
current
|
||||
.filter(|selected| values.iter().any(|value| value == *selected))
|
||||
.map(str::to_string)
|
||||
.or_else(|| values.first().cloned())
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option<String> {
|
||||
current
|
||||
.filter(|selected| values.iter().any(|value| value == *selected))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
|
||||
if samples.len() < 2 {
|
||||
@ -1040,6 +1055,104 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let state = Rc::clone(&state);
|
||||
let widgets = widgets.clone();
|
||||
let widgets_handle = widgets.clone();
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let tests = Rc::clone(&tests);
|
||||
let camera_combo = camera_combo.clone();
|
||||
let microphone_combo = microphone_combo.clone();
|
||||
let speaker_combo = speaker_combo.clone();
|
||||
let keyboard_combo = keyboard_combo.clone();
|
||||
let mouse_combo = mouse_combo.clone();
|
||||
widgets.device_refresh_button.connect_clicked(move |_| {
|
||||
let catalog = DeviceCatalog::discover();
|
||||
let (
|
||||
selected_camera,
|
||||
selected_microphone,
|
||||
selected_speaker,
|
||||
selected_keyboard,
|
||||
selected_mouse,
|
||||
) = {
|
||||
let state = state.borrow();
|
||||
(
|
||||
retained_stage_selection(
|
||||
state.devices.camera.as_deref(),
|
||||
&catalog.cameras,
|
||||
),
|
||||
retained_stage_selection(
|
||||
state.devices.microphone.as_deref(),
|
||||
&catalog.microphones,
|
||||
),
|
||||
retained_stage_selection(
|
||||
state.devices.speaker.as_deref(),
|
||||
&catalog.speakers,
|
||||
),
|
||||
retained_input_selection(
|
||||
state.devices.keyboard.as_deref(),
|
||||
&catalog.keyboards,
|
||||
),
|
||||
retained_input_selection(state.devices.mouse.as_deref(), &catalog.mice),
|
||||
)
|
||||
};
|
||||
{
|
||||
let mut state = state.borrow_mut();
|
||||
state.select_camera(selected_camera);
|
||||
state.select_microphone(selected_microphone);
|
||||
state.select_speaker(selected_speaker);
|
||||
state.select_keyboard(selected_keyboard);
|
||||
state.select_mouse(selected_mouse);
|
||||
}
|
||||
let state_snapshot = state.borrow().clone();
|
||||
sync_stage_device_combo(
|
||||
&camera_combo,
|
||||
&catalog.cameras,
|
||||
state_snapshot.devices.camera.as_deref(),
|
||||
);
|
||||
sync_stage_device_combo(
|
||||
µphone_combo,
|
||||
&catalog.microphones,
|
||||
state_snapshot.devices.microphone.as_deref(),
|
||||
);
|
||||
sync_stage_device_combo(
|
||||
&speaker_combo,
|
||||
&catalog.speakers,
|
||||
state_snapshot.devices.speaker.as_deref(),
|
||||
);
|
||||
sync_input_device_combo(
|
||||
&keyboard_combo,
|
||||
&catalog.keyboards,
|
||||
state_snapshot.devices.keyboard.as_deref(),
|
||||
"all keyboards",
|
||||
);
|
||||
sync_input_device_combo(
|
||||
&mouse_combo,
|
||||
&catalog.mice,
|
||||
state_snapshot.devices.mouse.as_deref(),
|
||||
"all mice",
|
||||
);
|
||||
if let Err(err) = tests
|
||||
.borrow_mut()
|
||||
.set_camera_selection(state_snapshot.devices.camera.as_deref())
|
||||
{
|
||||
widgets_handle
|
||||
.status_label
|
||||
.set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}"));
|
||||
} else {
|
||||
widgets_handle.status_label.set_text(
|
||||
"Device staging refreshed. Newly attached devices are ready for local tests; reconnect the relay if you want the live session to use a new webcam, mic, or speaker.",
|
||||
);
|
||||
}
|
||||
refresh_launcher_ui(
|
||||
&widgets_handle,
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
refresh_test_buttons(&widgets_handle, &mut tests.borrow_mut());
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let state = Rc::clone(&state);
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
|
||||
@ -55,6 +55,7 @@ pub struct LauncherWidgets {
|
||||
pub diagnostics_buffer: gtk::TextBuffer,
|
||||
pub diagnostics_scroll: gtk::ScrolledWindow,
|
||||
pub diagnostics_popout_scroll: Rc<RefCell<Option<gtk::ScrolledWindow>>>,
|
||||
pub diagnostics_rendered_text: Rc<RefCell<String>>,
|
||||
pub session_log_buffer: gtk::TextBuffer,
|
||||
pub session_log_view: gtk::TextView,
|
||||
pub summary: SummaryWidgets,
|
||||
@ -70,6 +71,7 @@ pub struct LauncherWidgets {
|
||||
pub clipboard_button: gtk::Button,
|
||||
pub probe_button: gtk::Button,
|
||||
pub usb_recover_button: gtk::Button,
|
||||
pub device_refresh_button: gtk::Button,
|
||||
pub swap_key_button: gtk::Button,
|
||||
pub camera_test_button: gtk::Button,
|
||||
pub microphone_test_button: gtk::Button,
|
||||
@ -201,6 +203,15 @@ pub fn build_launcher_view(
|
||||
devices_panel.set_hexpand(true);
|
||||
devices_panel.set_vexpand(false);
|
||||
devices_body.set_spacing(8);
|
||||
let devices_actions = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
devices_actions.set_halign(gtk::Align::End);
|
||||
let device_refresh_button = gtk::Button::with_label("Refresh Devices");
|
||||
stabilize_button(&device_refresh_button, 132);
|
||||
device_refresh_button.set_tooltip_text(Some(
|
||||
"Re-scan webcams, microphones, speakers, keyboards, and mice without restarting Lesavka.",
|
||||
));
|
||||
devices_actions.append(&device_refresh_button);
|
||||
devices_body.append(&devices_actions);
|
||||
|
||||
let control_group = build_subgroup("Control Inputs");
|
||||
let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
@ -307,7 +318,7 @@ pub fn build_launcher_view(
|
||||
devices_body.append(&media_group);
|
||||
staging_row.append(&devices_panel);
|
||||
|
||||
let (preview_panel, preview_body) = build_panel("Selected Camera Preview");
|
||||
let (preview_panel, preview_body) = build_panel("Webcam Test");
|
||||
preview_panel.set_hexpand(true);
|
||||
preview_panel.set_vexpand(false);
|
||||
preview_body.set_spacing(6);
|
||||
@ -321,7 +332,7 @@ pub fn build_launcher_view(
|
||||
);
|
||||
camera_preview.set_keep_aspect_ratio(true);
|
||||
camera_preview.add_css_class("camera-preview-frame");
|
||||
let camera_status = gtk::Label::new(Some("Select a camera and click Start Preview."));
|
||||
let camera_status = gtk::Label::new(Some("Select a webcam and click Start Preview."));
|
||||
camera_status.add_css_class("dim-label");
|
||||
camera_status.set_wrap(true);
|
||||
camera_status.set_xalign(0.0);
|
||||
@ -464,7 +475,7 @@ pub fn build_launcher_view(
|
||||
diagnostics_view.set_editable(false);
|
||||
diagnostics_view.set_cursor_visible(false);
|
||||
diagnostics_view.set_monospace(true);
|
||||
diagnostics_view.set_wrap_mode(gtk::WrapMode::WordChar);
|
||||
diagnostics_view.set_wrap_mode(gtk::WrapMode::None);
|
||||
let diagnostics_scroll = gtk::ScrolledWindow::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
@ -629,6 +640,7 @@ pub fn build_launcher_view(
|
||||
diagnostics_buffer: diagnostics_buffer.clone(),
|
||||
diagnostics_scroll: diagnostics_scroll.clone(),
|
||||
diagnostics_popout_scroll: diagnostics_popout_scroll.clone(),
|
||||
diagnostics_rendered_text: Rc::new(RefCell::new(String::new())),
|
||||
session_log_buffer: session_log_buffer.clone(),
|
||||
session_log_view: session_log_view.clone(),
|
||||
summary: SummaryWidgets {
|
||||
@ -652,6 +664,7 @@ pub fn build_launcher_view(
|
||||
clipboard_button: clipboard_button.clone(),
|
||||
probe_button: probe_button.clone(),
|
||||
usb_recover_button: usb_recover_button.clone(),
|
||||
device_refresh_button: device_refresh_button.clone(),
|
||||
swap_key_button: swap_key_button.clone(),
|
||||
camera_test_button: camera_test_button.clone(),
|
||||
microphone_test_button: microphone_test_button.clone(),
|
||||
@ -976,6 +989,33 @@ pub fn sync_breakout_size_combo(
|
||||
combo.set_active_id(Some(selected.as_id()));
|
||||
}
|
||||
|
||||
pub fn sync_stage_device_combo(
|
||||
combo: >k::ComboBoxText,
|
||||
values: &[String],
|
||||
selected: Option<&str>,
|
||||
) {
|
||||
combo.remove_all();
|
||||
combo.append(Some("auto"), "auto");
|
||||
for value in values {
|
||||
append_stage_choice(combo, value);
|
||||
}
|
||||
super::ui_runtime::set_combo_active_text(combo, selected);
|
||||
}
|
||||
|
||||
pub fn sync_input_device_combo(
|
||||
combo: >k::ComboBoxText,
|
||||
values: &[String],
|
||||
selected: Option<&str>,
|
||||
all_label: &str,
|
||||
) {
|
||||
combo.remove_all();
|
||||
combo.append(Some("all"), all_label);
|
||||
for value in values {
|
||||
append_input_choice(combo, value);
|
||||
}
|
||||
super::ui_runtime::set_combo_active_text(combo, selected);
|
||||
}
|
||||
|
||||
fn attach_device_row(
|
||||
grid: >k::Grid,
|
||||
row: i32,
|
||||
|
||||
@ -89,6 +89,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
widgets
|
||||
.usb_recover_button
|
||||
.set_sensitive(state.server_available);
|
||||
widgets.device_refresh_button.set_sensitive(true);
|
||||
widgets.input_toggle_button.set_label("Change Routing");
|
||||
widgets
|
||||
.input_toggle_button
|
||||
@ -947,6 +948,22 @@ pub fn refresh_diagnostics_report(
|
||||
state: &LauncherState,
|
||||
child_running: bool,
|
||||
) {
|
||||
let mut snapshot = SnapshotReport::from_state(
|
||||
state,
|
||||
&widgets.diagnostics_log.borrow(),
|
||||
quality_probe_command().to_string(),
|
||||
);
|
||||
if child_running && !snapshot.remote_active {
|
||||
snapshot.recommendations.insert(
|
||||
0,
|
||||
"The relay child is still alive while launcher state says inactive; give it a moment or reconnect before trusting throughput feel.".to_string(),
|
||||
);
|
||||
}
|
||||
let rendered = snapshot.to_pretty_text();
|
||||
if *widgets.diagnostics_rendered_text.borrow() == rendered {
|
||||
return;
|
||||
}
|
||||
|
||||
let diagnostics_adjustment = widgets.diagnostics_scroll.vadjustment();
|
||||
let previous_value = diagnostics_adjustment.value();
|
||||
let previous_max =
|
||||
@ -963,37 +980,29 @@ pub fn refresh_diagnostics_report(
|
||||
let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0);
|
||||
(adjustment.clone(), previous_value, was_at_bottom)
|
||||
});
|
||||
let mut snapshot = SnapshotReport::from_state(
|
||||
state,
|
||||
&widgets.diagnostics_log.borrow(),
|
||||
quality_probe_command().to_string(),
|
||||
);
|
||||
if child_running && !snapshot.remote_active {
|
||||
snapshot.recommendations.insert(
|
||||
0,
|
||||
"The relay child is still alive while launcher state says inactive; give it a moment or reconnect before trusting throughput feel.".to_string(),
|
||||
);
|
||||
}
|
||||
widgets
|
||||
.diagnostics_buffer
|
||||
.set_text(&snapshot.to_pretty_text());
|
||||
glib::idle_add_local_once(move || {
|
||||
let max = (diagnostics_adjustment.upper() - diagnostics_adjustment.page_size()).max(0.0);
|
||||
let target = if was_at_bottom {
|
||||
max
|
||||
} else {
|
||||
previous_value.min(max)
|
||||
};
|
||||
diagnostics_adjustment.set_value(target);
|
||||
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state {
|
||||
let restore_adjustment =
|
||||
|adjustment: >k::Adjustment, previous_value: f64, was_at_bottom: bool| {
|
||||
let max = (adjustment.upper() - adjustment.page_size()).max(0.0);
|
||||
let target = if was_at_bottom {
|
||||
max
|
||||
} else {
|
||||
previous_value.min(max)
|
||||
};
|
||||
if (adjustment.value() - target).abs() > 1.0 {
|
||||
adjustment.set_value(target);
|
||||
}
|
||||
};
|
||||
*widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone();
|
||||
widgets.diagnostics_buffer.set_text(&rendered);
|
||||
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
|
||||
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref() {
|
||||
restore_adjustment(adjustment, *previous_value, *was_at_bottom);
|
||||
}
|
||||
glib::idle_add_local_once(move || {
|
||||
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
|
||||
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state {
|
||||
restore_adjustment(&adjustment, previous_value, was_at_bottom);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1002,7 +1011,15 @@ pub fn open_session_log_popout(
|
||||
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
|
||||
buffer: >k::TextBuffer,
|
||||
) {
|
||||
open_text_buffer_popout(app, handle, None, buffer, "Lesavka Log", "Copy Log");
|
||||
open_text_buffer_popout(
|
||||
app,
|
||||
handle,
|
||||
None,
|
||||
buffer,
|
||||
"Lesavka Log",
|
||||
"Copy Log",
|
||||
gtk::WrapMode::WordChar,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn open_diagnostics_popout(
|
||||
@ -1018,6 +1035,7 @@ pub fn open_diagnostics_popout(
|
||||
buffer,
|
||||
"Lesavka Diagnostics",
|
||||
"Copy Report",
|
||||
gtk::WrapMode::None,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1028,6 +1046,7 @@ fn open_text_buffer_popout(
|
||||
buffer: >k::TextBuffer,
|
||||
title: &str,
|
||||
copy_button_label: &str,
|
||||
wrap_mode: gtk::WrapMode,
|
||||
) {
|
||||
if let Some(window) = handle.borrow().as_ref() {
|
||||
window.present();
|
||||
@ -1059,7 +1078,7 @@ fn open_text_buffer_popout(
|
||||
view.set_editable(false);
|
||||
view.set_cursor_visible(false);
|
||||
view.set_monospace(true);
|
||||
view.set_wrap_mode(gtk::WrapMode::WordChar);
|
||||
view.set_wrap_mode(wrap_mode);
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
|
||||
@ -26,15 +26,21 @@ impl AudioOut {
|
||||
let mut pipe = format!(
|
||||
"appsrc name=src is-live=true format=time do-timestamp=true \
|
||||
block=false ! \
|
||||
queue leaky=downstream ! \
|
||||
aacparse ! avdec_aac ! audioresample ! audioconvert ! {}",
|
||||
queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \
|
||||
aacparse ! avdec_aac ! \
|
||||
audioconvert ! audioresample ! \
|
||||
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
|
||||
queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {}",
|
||||
sink,
|
||||
);
|
||||
if tee_dump {
|
||||
pipe = format!(
|
||||
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
|
||||
tee name=t ! \
|
||||
queue leaky=downstream ! aacparse ! avdec_aac ! audioresample ! audioconvert ! {} \
|
||||
queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \
|
||||
aacparse ! avdec_aac ! audioconvert ! audioresample ! \
|
||||
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
|
||||
queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {} \
|
||||
t. ! queue ! filesink location=/tmp/lesavka-audio.aac",
|
||||
sink,
|
||||
);
|
||||
@ -177,7 +183,14 @@ fn normalize_sink_override(raw: &str) -> String {
|
||||
|
||||
fn pulsesink_device_element(device: &str) -> String {
|
||||
let escaped = device.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!("pulsesink device=\"{escaped}\"")
|
||||
let (buffer_time, latency_time) = if device.starts_with("bluez_output.") {
|
||||
(750_000, 250_000)
|
||||
} else {
|
||||
(350_000, 100_000)
|
||||
};
|
||||
format!(
|
||||
"pulsesink device=\"{escaped}\" buffer-time={buffer_time} latency-time={latency_time} sync=true"
|
||||
)
|
||||
}
|
||||
|
||||
fn list_pw_sinks() -> Vec<(String, String)> {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.24"
|
||||
version = "0.11.25"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -17,6 +17,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn banner_includes_version() {
|
||||
assert_eq!(banner("0.11.24"), "lesavka-common CLI (v0.11.24)");
|
||||
assert_eq!(banner("0.11.25"), "lesavka-common CLI (v0.11.25)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.24"
|
||||
version = "0.11.25"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ mod audio_include_contract {
|
||||
let sink = pick_sink_element().expect("device sink");
|
||||
assert_eq!(
|
||||
sink,
|
||||
"pulsesink device=\"alsa_output.pci-0000_00_1f.3.analog-stereo\""
|
||||
"pulsesink device=\"alsa_output.pci-0000_00_1f.3.analog-stereo\" buffer-time=350000 latency-time=100000 sync=true"
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -86,12 +86,28 @@ exit 0
|
||||
let sink = pick_sink_element().expect("pick sink");
|
||||
assert_eq!(
|
||||
sink,
|
||||
"pulsesink device=\"alsa_output.usb-DAC_1234-00.analog-stereo\""
|
||||
"pulsesink device=\"alsa_output.usb-DAC_1234-00.analog-stereo\" buffer-time=350000 latency-time=100000 sync=true"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn bluetooth_sink_override_uses_more_headroom() {
|
||||
with_var(
|
||||
"LESAVKA_AUDIO_SINK",
|
||||
Some("bluez_output.80_C3_BA_76_26_AB.1"),
|
||||
|| {
|
||||
let sink = pick_sink_element().expect("bluetooth sink");
|
||||
assert_eq!(
|
||||
sink,
|
||||
"pulsesink device=\"bluez_output.80_C3_BA_76_26_AB.1\" buffer-time=750000 latency-time=250000 sync=true"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn pick_sink_element_falls_back_to_autoaudiosink_without_pactl_default() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user