launcher(ui): tighten recovery rail, chip health, and capture fidelity
This commit is contained in:
parent
21ad7c6ee9
commit
9401f2b7cd
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_client"
|
||||
version = "0.15.2"
|
||||
version = "0.15.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@ -1676,7 +1676,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_common"
|
||||
version = "0.15.2"
|
||||
version = "0.15.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@ -1688,7 +1688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_server"
|
||||
version = "0.15.2"
|
||||
version = "0.15.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.15.2"
|
||||
version = "0.15.3"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -179,18 +179,16 @@ fn build_preview_pipeline(
|
||||
profile: PreviewProfile,
|
||||
decoder_name: &str,
|
||||
) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> {
|
||||
let source_mode = eye_source_mode_for_request(
|
||||
let _display_bounds = (profile.display_width, profile.display_height);
|
||||
let _source_mode = eye_source_mode_for_request(
|
||||
profile.requested_width.max(2) as u32,
|
||||
profile.requested_height.max(2) as u32,
|
||||
);
|
||||
let (render_width, render_height) =
|
||||
preview_render_size(profile, source_mode.width, source_mode.height);
|
||||
let desc = format!(
|
||||
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
|
||||
queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \
|
||||
h264parse name=preview_parse disable-passthrough=true ! {} name=decoder ! videoconvert ! \
|
||||
videoscale add-borders=false ! \
|
||||
video/x-raw,format=RGBA,width=(int){render_width},height=(int){render_height},pixel-aspect-ratio=1/1 ! \
|
||||
video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \
|
||||
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true",
|
||||
decoder_name,
|
||||
);
|
||||
@ -219,8 +217,6 @@ fn build_preview_pipeline(
|
||||
appsink.set_caps(Some(
|
||||
&gst::Caps::builder("video/x-raw")
|
||||
.field("format", "RGBA")
|
||||
.field("width", render_width)
|
||||
.field("height", render_height)
|
||||
.field("pixel-aspect-ratio", gst::Fraction::new(1, 1))
|
||||
.build(),
|
||||
));
|
||||
@ -228,27 +224,6 @@ fn build_preview_pipeline(
|
||||
Ok((pipeline, appsrc, appsink, decoder_name.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn preview_render_size(
|
||||
profile: PreviewProfile,
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
) -> (i32, i32) {
|
||||
fn round_down_even(value: i32) -> i32 {
|
||||
let clamped = value.max(2);
|
||||
clamped - (clamped % 2)
|
||||
}
|
||||
|
||||
let source_w = source_width.max(2) as f32;
|
||||
let source_h = source_height.max(2) as f32;
|
||||
let max_w = profile.display_width.max(2) as f32;
|
||||
let max_h = profile.display_height.max(2) as f32;
|
||||
let scale = (max_w / source_w).min(max_h / source_h).clamp(0.01, 1.0);
|
||||
let render_w = round_down_even((source_w * scale).round() as i32);
|
||||
let render_h = round_down_even((source_h * scale).round() as i32);
|
||||
(render_w.max(2), render_h.max(2))
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn preview_decoder_candidates() -> Vec<String> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
@ -2,7 +2,7 @@ use super::{
|
||||
DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT,
|
||||
INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH,
|
||||
LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry,
|
||||
preview_render_size, sanitize_preview_request,
|
||||
sanitize_preview_request,
|
||||
};
|
||||
use crate::launcher::state::{CaptureSizePreset, LauncherState};
|
||||
use futures::stream;
|
||||
@ -155,18 +155,6 @@ fn breakout_preview_profile_defaults_to_higher_quality() {
|
||||
assert_eq!(profile.max_bitrate_kbit, 18_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_render_size_fits_source_into_display_budget() {
|
||||
let profile = PreviewSurface::Inline.profile();
|
||||
assert_eq!(preview_render_size(profile, 1920, 1080), (960, 540));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_render_size_never_upscales_beyond_source_geometry() {
|
||||
let profile = PreviewSurface::Window.profile();
|
||||
assert_eq!(preview_render_size(profile, 1280, 720), (1280, 720));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_request_sanitizer_keeps_requested_source_geometry() {
|
||||
let adapted = sanitize_preview_request(1920, 1080, 60, 18_000);
|
||||
|
||||
@ -377,7 +377,7 @@ fn breakout_size_changes_resize_the_open_popout_window() {
|
||||
fn server_chip_state_tracks_connection_not_just_reachability() {
|
||||
let mut state = LauncherState::new();
|
||||
assert_eq!(server_light_state(&state, false), StatusLightState::Idle);
|
||||
assert_eq!(server_version_label(&state), "-");
|
||||
assert_eq!(server_version_label(&state), "???");
|
||||
|
||||
state.set_server_available(true);
|
||||
state.set_server_version(Some(crate::VERSION.to_string()));
|
||||
@ -396,7 +396,7 @@ fn server_chip_state_tracks_connection_not_just_reachability() {
|
||||
|
||||
state.set_server_version(Some(" ".to_string()));
|
||||
assert_eq!(server_light_state(&state, false), StatusLightState::Idle);
|
||||
assert_eq!(server_version_label(&state), "-");
|
||||
assert_eq!(server_version_label(&state), "???");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -8,6 +8,7 @@ use {
|
||||
super::diagnostics::PerformanceSample,
|
||||
super::launcher_clipboard_control_path,
|
||||
super::launcher_focus_signal_path,
|
||||
super::preview::{LauncherPreview, PreviewSurface},
|
||||
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
|
||||
super::state::{
|
||||
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
|
||||
|
||||
@ -157,6 +157,8 @@
|
||||
let mut state = state.borrow_mut();
|
||||
state.set_server_available(false);
|
||||
state.set_server_version(None);
|
||||
state.set_server_media_caps(None, None, None, None);
|
||||
state.set_capture_power(CapturePowerStatus::default());
|
||||
}
|
||||
if let Some(preview) = preview.as_ref() {
|
||||
preview.set_server_addr(server_addr.clone());
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
{
|
||||
const EYE_RECORD_FPS: u32 = 20;
|
||||
const EYE_RECORD_FRAME_INTERVAL_MS: u64 = 1000 / EYE_RECORD_FPS as u64;
|
||||
const DEFAULT_EYE_RECORD_FPS: u32 = 30;
|
||||
|
||||
#[derive(Default)]
|
||||
struct EyeRecordState {
|
||||
@ -10,6 +9,8 @@
|
||||
output_path: Option<PathBuf>,
|
||||
next_frame_index: u32,
|
||||
captured_frames: u32,
|
||||
encode_fps: u32,
|
||||
encode_bitrate_kbit: u32,
|
||||
}
|
||||
|
||||
fn eye_slug(title: &str) -> &'static str {
|
||||
@ -97,6 +98,35 @@
|
||||
.map_err(|err| format!("could not write {}: {err}", output_path.display()))
|
||||
}
|
||||
|
||||
fn recording_interval_ms(record_fps: u32) -> u64 {
|
||||
let fps = record_fps.max(1);
|
||||
(1000_u64 / fps as u64).max(1)
|
||||
}
|
||||
|
||||
fn best_effort_recording_profile(
|
||||
state: &LauncherState,
|
||||
preview: Option<&LauncherPreview>,
|
||||
monitor_id: usize,
|
||||
) -> (u32, u32) {
|
||||
let choice = state
|
||||
.display_capture_size_choice(monitor_id)
|
||||
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
|
||||
let mut fps = if choice.fps == 0 {
|
||||
DEFAULT_EYE_RECORD_FPS
|
||||
} else {
|
||||
choice.fps.max(1)
|
||||
};
|
||||
if let Some(snapshot) =
|
||||
preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline))
|
||||
&& snapshot.server_fps.is_finite()
|
||||
&& snapshot.server_fps >= 1.0
|
||||
{
|
||||
fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32;
|
||||
}
|
||||
let bitrate_kbit = choice.max_bitrate_kbit.max(800);
|
||||
(fps, bitrate_kbit)
|
||||
}
|
||||
|
||||
fn write_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> {
|
||||
let frame_dir = state
|
||||
.frame_dir
|
||||
@ -121,8 +151,12 @@
|
||||
.take()
|
||||
.ok_or_else(|| "recording output path was not initialized".to_string())?;
|
||||
let captured_frames = state.captured_frames;
|
||||
let encode_fps = state.encode_fps.max(1);
|
||||
let encode_bitrate_kbit = state.encode_bitrate_kbit.max(800);
|
||||
state.captured_frames = 0;
|
||||
state.next_frame_index = 0;
|
||||
state.encode_fps = 0;
|
||||
state.encode_bitrate_kbit = 0;
|
||||
|
||||
if captured_frames < 2 {
|
||||
let _ = std::fs::remove_dir_all(&frame_dir);
|
||||
@ -130,6 +164,7 @@
|
||||
}
|
||||
|
||||
let frame_pattern = frame_dir.join("frame-%06d.png");
|
||||
let bitrate_arg = format!("{encode_bitrate_kbit}k");
|
||||
let encode = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-hide_banner",
|
||||
@ -137,13 +172,17 @@
|
||||
"error",
|
||||
"-y",
|
||||
"-framerate",
|
||||
&EYE_RECORD_FPS.to_string(),
|
||||
&encode_fps.to_string(),
|
||||
"-i",
|
||||
&frame_pattern.to_string_lossy(),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-r",
|
||||
&encode_fps.to_string(),
|
||||
"-b:v",
|
||||
&bitrate_arg,
|
||||
&output_path.to_string_lossy(),
|
||||
])
|
||||
.status()
|
||||
@ -300,6 +339,8 @@
|
||||
let pane = pane.clone();
|
||||
let widgets = widgets_for_ui.clone();
|
||||
let save_state = Rc::clone(&save_state);
|
||||
let state = Rc::clone(&state);
|
||||
let preview = preview.clone();
|
||||
let record_button = pane.record_button.clone();
|
||||
record_button.connect_clicked(move |button| {
|
||||
if save_state.borrow().timer.is_some() {
|
||||
@ -330,6 +371,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let (record_fps, record_bitrate_kbit) = {
|
||||
let state = state.borrow();
|
||||
best_effort_recording_profile(&state, preview.as_deref(), monitor_id)
|
||||
};
|
||||
let root = {
|
||||
let borrowed = save_state.borrow();
|
||||
match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) {
|
||||
@ -361,13 +406,15 @@
|
||||
state.output_path = Some(output_path.clone());
|
||||
state.next_frame_index = 0;
|
||||
state.captured_frames = 0;
|
||||
state.encode_fps = record_fps;
|
||||
state.encode_bitrate_kbit = record_bitrate_kbit;
|
||||
}
|
||||
|
||||
let pane_for_tick = pane.clone();
|
||||
let widgets_for_tick = widgets.clone();
|
||||
let save_state_for_tick = Rc::clone(&save_state);
|
||||
let timer = glib::timeout_add_local(
|
||||
Duration::from_millis(EYE_RECORD_FRAME_INTERVAL_MS),
|
||||
Duration::from_millis(recording_interval_ms(record_fps)),
|
||||
move || {
|
||||
let mut state = save_state_for_tick.borrow_mut();
|
||||
if state.frame_dir.is_none() {
|
||||
@ -385,8 +432,8 @@
|
||||
save_state.borrow_mut().timer = Some(timer);
|
||||
button.set_label("Stop");
|
||||
widgets.status_label.set_text(&format!(
|
||||
"Recording {}... press Stop to finish.",
|
||||
pane.title
|
||||
"Recording {} at {} fps (~{} kbit)... press Stop to finish.",
|
||||
pane.title, record_fps, record_bitrate_kbit
|
||||
));
|
||||
});
|
||||
}
|
||||
@ -400,7 +447,7 @@
|
||||
widgets.usb_recover_button.connect_clicked(move |_| {
|
||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_for_click.status_label.set_text(
|
||||
"Requesting a forced USB gadget re-enumeration on the relay host...",
|
||||
"Recover USB 1/3: sending gadget reset request to relay host...",
|
||||
);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
@ -411,20 +458,20 @@
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
widgets.status_label.set_text(
|
||||
"USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.",
|
||||
"Recover USB 2/3: relay acknowledged reset. Recover USB 3/3: waiting for USB/UAC/UVC chips to settle.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("USB gadget recovery failed: {err}"));
|
||||
.set_text(&format!("Recover USB failed: {err}"));
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
widgets.status_label.set_text(
|
||||
"USB gadget recovery ended unexpectedly before the relay answered.",
|
||||
"Recover USB failed: relay stopped responding before completion.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
@ -441,7 +488,7 @@
|
||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_for_click
|
||||
.status_label
|
||||
.set_text("Requesting UAC recovery (USB gadget rebuild) on the relay host...");
|
||||
.set_text("Recover UAC 1/3: sending gadget reset request to relay host...");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
|
||||
@ -451,20 +498,20 @@
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
widgets.status_label.set_text(
|
||||
"UAC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate audio.",
|
||||
"Recover UAC 2/3: relay acknowledged reset. Recover UAC 3/3: waiting for UAC chip to settle.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("UAC recovery failed: {err}"));
|
||||
.set_text(&format!("Recover UAC failed: {err}"));
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
widgets.status_label.set_text(
|
||||
"UAC recovery ended unexpectedly before the relay answered.",
|
||||
"Recover UAC failed: relay stopped responding before completion.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
@ -481,7 +528,7 @@
|
||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_for_click
|
||||
.status_label
|
||||
.set_text("Requesting UVC recovery (USB gadget rebuild) on the relay host...");
|
||||
.set_text("Recover UVC 1/3: sending gadget reset request to relay host...");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
|
||||
@ -491,20 +538,20 @@
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
widgets.status_label.set_text(
|
||||
"UVC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate webcam video.",
|
||||
"Recover UVC 2/3: relay acknowledged reset. Recover UVC 3/3: waiting for UVC chip to settle.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("UVC recovery failed: {err}"));
|
||||
.set_text(&format!("Recover UVC failed: {err}"));
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
widgets.status_label.set_text(
|
||||
"UVC recovery ended unexpectedly before the relay answered.",
|
||||
"Recover UVC failed: relay stopped responding before completion.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ pub fn build_launcher_view(
|
||||
camera_preview_stack,
|
||||
camera_preview_frame,
|
||||
camera_preview,
|
||||
webcam_transport_combo,
|
||||
camera_mirror_button,
|
||||
camera_mirror_revealer,
|
||||
camera_status,
|
||||
|
||||
@ -149,6 +149,7 @@
|
||||
swap_key_button: swap_key_button.clone(),
|
||||
camera_test_button: camera_test_button.clone(),
|
||||
camera_preview_stack: camera_preview_stack.clone(),
|
||||
webcam_transport_combo: webcam_transport_combo.clone(),
|
||||
camera_mirror_button: camera_mirror_button.clone(),
|
||||
camera_mirror_revealer: camera_mirror_revealer.clone(),
|
||||
microphone_test_button: microphone_test_button.clone(),
|
||||
@ -184,6 +185,7 @@
|
||||
camera_preview_stack,
|
||||
camera_preview_frame,
|
||||
camera_preview,
|
||||
webcam_transport_combo,
|
||||
camera_mirror_button,
|
||||
camera_status,
|
||||
},
|
||||
|
||||
@ -42,6 +42,7 @@ struct DeviceControlsContext {
|
||||
camera_preview_stack: gtk::Stack,
|
||||
camera_preview_frame: gtk::AspectFrame,
|
||||
camera_preview: gtk::Picture,
|
||||
webcam_transport_combo: gtk::ComboBoxText,
|
||||
camera_mirror_button: gtk::ToggleButton,
|
||||
camera_mirror_revealer: gtk::Revealer,
|
||||
camera_status: gtk::Label,
|
||||
|
||||
@ -328,7 +328,19 @@
|
||||
camera_preview_stack.add_named(&camera_preview_overlay, Some("live"));
|
||||
camera_preview_stack.set_visible_child_name("idle");
|
||||
camera_preview_shell.append(&camera_preview_stack);
|
||||
let webcam_group = build_subgroup("Webcam Preview");
|
||||
let webcam_transport_combo = gtk::ComboBoxText::new();
|
||||
webcam_transport_combo.add_css_class("compact-combo");
|
||||
webcam_transport_combo.append(Some("mjpeg"), "MJPEG");
|
||||
webcam_transport_combo.append(Some("yuy2"), "YUY2");
|
||||
webcam_transport_combo.append(Some("h264"), "H.264");
|
||||
webcam_transport_combo.set_active_id(Some("mjpeg"));
|
||||
webcam_transport_combo.set_sensitive(false);
|
||||
webcam_transport_combo.set_size_request(112, -1);
|
||||
webcam_transport_combo.set_tooltip_text(Some(
|
||||
"Upstream transport format. MJPEG is pinned while sync hardening is in progress.",
|
||||
));
|
||||
let webcam_group =
|
||||
build_subgroup_with_action("Webcam Preview", Some(webcam_transport_combo.upcast_ref()));
|
||||
webcam_group.set_hexpand(true);
|
||||
webcam_group.set_vexpand(true);
|
||||
webcam_group.set_valign(gtk::Align::Fill);
|
||||
@ -383,6 +395,7 @@
|
||||
camera_preview_stack,
|
||||
camera_preview_frame,
|
||||
camera_preview,
|
||||
webcam_transport_combo,
|
||||
camera_mirror_button,
|
||||
camera_mirror_revealer,
|
||||
camera_status,
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
connection_body.append(&relay_grid);
|
||||
|
||||
let recovery_heading = gtk::Label::new(Some("Recovery"));
|
||||
let recovery_heading = gtk::Label::new(Some("Recover"));
|
||||
recovery_heading.add_css_class("subgroup-title");
|
||||
recovery_heading.set_halign(gtk::Align::Start);
|
||||
let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
@ -29,14 +29,15 @@
|
||||
let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
recovery_buttons.set_hexpand(true);
|
||||
recovery_buttons.set_homogeneous(true);
|
||||
let usb_recover_button = rail_button("Recover USB", "Re-enumerate remote USB gadget.");
|
||||
let uac_recover_button = rail_button("Recover UAC", "Rebuild remote USB audio function.");
|
||||
let uvc_recover_button = rail_button("Recover UVC", "Rebuild remote USB webcam function.");
|
||||
let usb_recover_button = rail_button("USB", "Re-enumerate remote USB gadget.");
|
||||
let uac_recover_button = rail_button("UAC", "Rebuild remote USB audio function.");
|
||||
let uvc_recover_button = rail_button("UVC", "Rebuild remote USB webcam function.");
|
||||
recovery_buttons.append(&usb_recover_button);
|
||||
recovery_buttons.append(&uac_recover_button);
|
||||
recovery_buttons.append(&uvc_recover_button);
|
||||
recovery_row.append(&recovery_buttons);
|
||||
connection_body.append(&recovery_row);
|
||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||
|
||||
let tools_heading = gtk::Label::new(Some("Tools"));
|
||||
tools_heading.add_css_class("subgroup-title");
|
||||
|
||||
@ -17,21 +17,25 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||
stream_status.set_max_width_chars(18);
|
||||
stream_status.set_tooltip_text(Some("Eye stream status."));
|
||||
|
||||
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
let header_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
header_row.set_hexpand(true);
|
||||
let title_label = gtk::Label::new(Some(title));
|
||||
title_label.add_css_class("title-4");
|
||||
title_label.set_halign(gtk::Align::Start);
|
||||
title_label.set_hexpand(false);
|
||||
title_label.set_hexpand(true);
|
||||
let capture_label = gtk::Label::new(Some(capture_path));
|
||||
capture_label.add_css_class("dim-label");
|
||||
capture_label.set_halign(gtk::Align::End);
|
||||
capture_label.set_hexpand(false);
|
||||
capture_label.set_ellipsize(pango::EllipsizeMode::Start);
|
||||
header.append(&title_label);
|
||||
header.append(&stream_status);
|
||||
header.append(&capture_label);
|
||||
root.append(&header);
|
||||
header_row.append(&title_label);
|
||||
header_row.append(&capture_label);
|
||||
let header_overlay = gtk::Overlay::new();
|
||||
header_overlay.set_hexpand(true);
|
||||
header_overlay.set_child(Some(&header_row));
|
||||
header_overlay.add_overlay(&stream_status);
|
||||
header_overlay.set_clip_overlay(&stream_status, true);
|
||||
root.append(&header_overlay);
|
||||
|
||||
let picture = gtk::Picture::new();
|
||||
picture.add_css_class("eye-preview-surface");
|
||||
|
||||
@ -25,12 +25,23 @@ fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::B
|
||||
}
|
||||
|
||||
fn build_subgroup(title: &str) -> gtk::Box {
|
||||
build_subgroup_with_action(title, None)
|
||||
}
|
||||
|
||||
fn build_subgroup_with_action(title: &str, action: Option<>k::Widget>) -> gtk::Box {
|
||||
let group = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
group.add_css_class("subgroup");
|
||||
let heading_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
heading_row.set_hexpand(true);
|
||||
let heading = gtk::Label::new(Some(title));
|
||||
heading.add_css_class("subgroup-title");
|
||||
heading.set_halign(gtk::Align::Start);
|
||||
group.append(&heading);
|
||||
heading.set_hexpand(true);
|
||||
heading_row.append(&heading);
|
||||
if let Some(action) = action {
|
||||
heading_row.append(action);
|
||||
}
|
||||
group.append(&heading_row);
|
||||
group
|
||||
}
|
||||
|
||||
@ -38,6 +49,7 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
|
||||
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
chip.add_css_class("status-chip");
|
||||
chip.set_hexpand(false);
|
||||
chip.set_size_request(96, -1);
|
||||
|
||||
let label_widget = gtk::Label::new(Some(label));
|
||||
label_widget.add_css_class("status-chip-label");
|
||||
@ -47,6 +59,9 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
|
||||
value_widget.add_css_class("status-chip-value");
|
||||
value_widget.set_halign(gtk::Align::Center);
|
||||
value_widget.set_xalign(0.5);
|
||||
value_widget.set_ellipsize(pango::EllipsizeMode::End);
|
||||
value_widget.set_single_line_mode(true);
|
||||
value_widget.set_width_chars(9);
|
||||
chip.append(&label_widget);
|
||||
chip.append(&value_widget);
|
||||
(chip, value_widget)
|
||||
@ -56,6 +71,7 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box
|
||||
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
chip.add_css_class("status-chip");
|
||||
chip.set_hexpand(false);
|
||||
chip.set_size_request(96, -1);
|
||||
|
||||
let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
meta.add_css_class("status-chip-meta");
|
||||
@ -73,6 +89,9 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box
|
||||
value_widget.add_css_class("status-chip-value");
|
||||
value_widget.set_halign(gtk::Align::Center);
|
||||
value_widget.set_xalign(0.5);
|
||||
value_widget.set_ellipsize(pango::EllipsizeMode::End);
|
||||
value_widget.set_single_line_mode(true);
|
||||
value_widget.set_width_chars(9);
|
||||
chip.append(&meta);
|
||||
chip.append(&value_widget);
|
||||
(chip, light, value_widget)
|
||||
|
||||
@ -156,6 +156,7 @@ pub struct LauncherWidgets {
|
||||
pub swap_key_button: gtk::Button,
|
||||
pub camera_test_button: gtk::Button,
|
||||
pub camera_preview_stack: gtk::Stack,
|
||||
pub webcam_transport_combo: gtk::ComboBoxText,
|
||||
pub camera_mirror_button: gtk::ToggleButton,
|
||||
pub camera_mirror_revealer: gtk::Revealer,
|
||||
pub microphone_test_button: gtk::Button,
|
||||
@ -178,6 +179,7 @@ pub struct DeviceStageWidgets {
|
||||
pub camera_preview_stack: gtk::Stack,
|
||||
pub camera_preview_frame: gtk::AspectFrame,
|
||||
pub camera_preview: gtk::Picture,
|
||||
pub webcam_transport_combo: gtk::ComboBoxText,
|
||||
pub camera_mirror_button: gtk::ToggleButton,
|
||||
pub camera_status: gtk::Label,
|
||||
}
|
||||
|
||||
@ -103,7 +103,7 @@ fn normalize_version(version: &str) -> &str {
|
||||
|
||||
fn server_version_label(state: &LauncherState) -> String {
|
||||
if !state.server_available {
|
||||
return "-".to_string();
|
||||
return "???".to_string();
|
||||
}
|
||||
let version = state
|
||||
.server_version
|
||||
@ -113,7 +113,7 @@ fn server_version_label(state: &LauncherState) -> String {
|
||||
match version {
|
||||
Some(version) if version.starts_with('v') => version.to_string(),
|
||||
Some(version) => format!("v{version}"),
|
||||
None => "-".to_string(),
|
||||
None => "???".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -44,14 +44,16 @@ pub type RelayChild = Child;
|
||||
|
||||
pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) {
|
||||
let relay_live = child_running || state.remote_active;
|
||||
let server_label = server_version_label(state);
|
||||
set_status_light(
|
||||
&widgets.summary.relay_light,
|
||||
server_light_state(state, relay_live),
|
||||
);
|
||||
widgets.summary.relay_value.set_text(&server_label);
|
||||
widgets
|
||||
.summary
|
||||
.relay_value
|
||||
.set_text(&server_version_label(state));
|
||||
.set_tooltip_text(Some(&server_label));
|
||||
set_status_light(
|
||||
&widgets.summary.routing_light,
|
||||
StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)),
|
||||
@ -60,14 +62,21 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
.summary
|
||||
.routing_value
|
||||
.set_text(&capitalize(routing_name(state.routing)));
|
||||
let gpio_label = if state.server_available {
|
||||
gpio_power_label(&state.capture_power)
|
||||
} else {
|
||||
"Offline".to_string()
|
||||
};
|
||||
set_status_light(
|
||||
&widgets.summary.gpio_light,
|
||||
gpio_light_state(&state.capture_power),
|
||||
if state.server_available {
|
||||
gpio_light_state(&state.capture_power)
|
||||
} else {
|
||||
StatusLightState::Idle
|
||||
},
|
||||
);
|
||||
widgets
|
||||
.summary
|
||||
.gpio_value
|
||||
.set_text(&gpio_power_label(&state.capture_power));
|
||||
widgets.summary.gpio_value.set_text(&gpio_label);
|
||||
widgets.summary.gpio_value.set_tooltip_text(Some(&gpio_label));
|
||||
widgets
|
||||
.summary
|
||||
.shortcut_value
|
||||
@ -76,16 +85,22 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
let (usb_state, usb_value) = recovery_usb_health(state);
|
||||
set_status_light(&widgets.summary.usb_light, usb_state);
|
||||
widgets.summary.usb_value.set_text(&usb_value);
|
||||
widgets.summary.usb_value.set_tooltip_text(Some(&usb_value));
|
||||
let (uac_state, uac_value) = recovery_uac_health(state);
|
||||
set_status_light(&widgets.summary.uac_light, uac_state);
|
||||
widgets.summary.uac_value.set_text(&uac_value);
|
||||
widgets.summary.uac_value.set_tooltip_text(Some(&uac_value));
|
||||
let (uvc_state, uvc_value) = recovery_uvc_health(state);
|
||||
set_status_light(&widgets.summary.uvc_light, uvc_state);
|
||||
widgets.summary.uvc_value.set_text(&uvc_value);
|
||||
widgets.summary.uvc_value.set_tooltip_text(Some(&uvc_value));
|
||||
|
||||
widgets
|
||||
.power_detail
|
||||
.set_text(&capture_power_detail(&state.capture_power));
|
||||
let power_detail = if state.server_available {
|
||||
capture_power_detail(&state.capture_power)
|
||||
} else {
|
||||
"relay host is offline; GPIO power state is unavailable".to_string()
|
||||
};
|
||||
widgets.power_detail.set_text(&power_detail);
|
||||
if (widgets.audio_gain_scale.value() - state.audio_gain_percent as f64).abs() > f64::EPSILON {
|
||||
widgets
|
||||
.audio_gain_scale
|
||||
@ -177,6 +192,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
widgets
|
||||
.speaker_test_button
|
||||
.set_sensitive(!relay_live && state.channels.audio);
|
||||
widgets.webcam_transport_combo.set_sensitive(false);
|
||||
widgets.input_toggle_button.set_label(match state.routing {
|
||||
InputRouting::Remote => "Route Local",
|
||||
InputRouting::Local => "Route Remote",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.15.2"
|
||||
version = "0.15.3"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.15.2"
|
||||
version = "0.15.3"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -88,13 +88,13 @@ fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width()
|
||||
assert!(UI_LAYOUT_SRC.contains("capture_label.set_ellipsize(pango::EllipsizeMode::Start);"));
|
||||
assert!(!UI_LAYOUT_SRC.contains("root.append(&stream_status);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let header_overlay = gtk::Overlay::new();"));
|
||||
assert!(UI_LAYOUT_SRC.contains("header_row.append(&title_label);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("header_row.append(&capture_label);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("header_overlay.add_overlay(&stream_status);"));
|
||||
assert!(
|
||||
source_index("header.append(&title_label);")
|
||||
< source_index("header.append(&stream_status);")
|
||||
);
|
||||
assert!(
|
||||
source_index("header.append(&stream_status);")
|
||||
< source_index("header.append(&capture_label);")
|
||||
source_index("header_row.append(&title_label);")
|
||||
< source_index("header_row.append(&capture_label);")
|
||||
);
|
||||
assert!(
|
||||
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
|
||||
@ -281,7 +281,7 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
|
||||
assert!(UI_LAYOUT_SRC.contains("let start_button = rail_button(\"Connect\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label("));
|
||||
assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recovery\"));"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recover\"));"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);"));
|
||||
@ -292,9 +292,9 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
|
||||
assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"Recover USB\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(\"Recover UAC\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(\"Recover UVC\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"USB\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(\"UAC\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(\"UVC\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);"));
|
||||
@ -373,8 +373,8 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() {
|
||||
UI_LAYOUT_SRC
|
||||
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
||||
.count(),
|
||||
2,
|
||||
"the operations rail should not gain extra vertical sections that stretch the lower layout"
|
||||
3,
|
||||
"recover/tools/gpio/inputs sections should remain visually separated"
|
||||
);
|
||||
assert!(
|
||||
source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")
|
||||
|
||||
@ -174,10 +174,10 @@ fn launcher_utility_buttons_still_bind_to_live_actions() {
|
||||
assert!(UI_SRC.contains("clip saved to"));
|
||||
assert!(UI_SRC.contains("record_button.connect_clicked"));
|
||||
assert!(UI_SRC.contains("recording saved to"));
|
||||
assert!(UI_SRC.contains("Recording {}... press Stop to finish."));
|
||||
assert!(UI_SRC.contains("press Stop to finish."));
|
||||
assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked"));
|
||||
assert!(UI_SRC.contains("reset_usb_gadget(&server_addr)"));
|
||||
assert!(UI_SRC.contains("USB gadget recovery requested."));
|
||||
assert!(UI_SRC.contains("Recover USB 2/3: relay acknowledged reset."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -188,7 +188,7 @@ fn server_chip_distinguishes_reachable_from_connected() {
|
||||
assert!(UI_RUNTIME_SRC.contains("} else if relay_live {"));
|
||||
assert!(UI_RUNTIME_SRC.contains("StatusLightState::Caution"));
|
||||
assert!(UI_RUNTIME_SRC.contains("fn server_version_label("));
|
||||
assert!(UI_RUNTIME_SRC.contains("return \"-\".to_string();"));
|
||||
assert!(UI_RUNTIME_SRC.contains("return \"???\".to_string();"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user