lesavka/client/src/launcher/ui_runtime/status_details.rs

428 lines
14 KiB
Rust

pub fn gpio_power_label(power: &CapturePowerStatus) -> String {
if !power.available {
return "Unavailable".to_string();
}
if !power.enabled {
return "Power Off".to_string();
}
match power.detected_devices {
0 => "No Eyes".to_string(),
1 => "1 Eye".to_string(),
count => format!("{count} Eyes"),
}
}
pub fn capture_power_detail(power: &CapturePowerStatus) -> String {
if !power.available {
return format!("{} is unavailable: {}", power.unit, power.detail);
}
let detected = if power.enabled {
format!("{}", gpio_detection_detail(power.detected_devices))
} else {
String::new()
};
match power.mode.as_str() {
"forced-on" => format!(
"{} • awake • {}{} • leases {}",
power.unit, power.detail, detected, power.active_leases
),
"forced-off" => format!(
"{} • dark • {}{} • leases {}",
power.unit, power.detail, detected, power.active_leases
),
_ => format!(
"{} • auto • {}{} • leases {}",
power.unit, power.detail, detected, power.active_leases
),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum StatusLightState {
Idle,
Live,
Connected,
Warning,
Caution,
}
impl StatusLightState {
fn from_active(active: bool) -> Self {
if active { Self::Live } else { Self::Idle }
}
fn css_class(self) -> &'static str {
match self {
Self::Idle => "status-light-idle",
Self::Live => "status-light-live",
Self::Connected => "status-light-connected",
Self::Warning => "status-light-warning",
Self::Caution => "status-light-caution",
}
}
}
fn server_light_state(state: &LauncherState, relay_live: bool) -> StatusLightState {
if !state.server_available || !server_version_known(state) {
StatusLightState::Idle
} else if server_versions_match(state) {
if relay_live {
StatusLightState::Connected
} else {
StatusLightState::Live
}
} else if relay_live {
StatusLightState::Caution
} else {
StatusLightState::Warning
}
}
/// Confirms the server reported a usable version before claiming compatibility.
fn server_version_known(state: &LauncherState) -> bool {
state
.server_version
.as_deref()
.map(normalize_version)
.is_some_and(|version| !version.is_empty())
}
/// Compares the reachable server version against this client build.
fn server_versions_match(state: &LauncherState) -> bool {
state
.server_version
.as_deref()
.map(normalize_version)
.is_some_and(|server_version| server_version == normalize_version(crate::VERSION))
}
/// Compares versions independent of whether the source included a leading `v`.
fn normalize_version(version: &str) -> &str {
version.trim().trim_start_matches('v')
}
/// Show the connected server version, or an explicit unknown marker when disconnected.
fn server_version_label(state: &LauncherState) -> String {
if !state.server_available {
return "???".to_string();
}
let version = state
.server_version
.as_deref()
.map(str::trim)
.filter(|version| !version.is_empty());
match version {
Some(version) if version.starts_with('v') => version.to_string(),
Some(version) => format!("v{version}"),
None => "???".to_string(),
}
}
/// Summarize whether the composite USB gadget appears reachable to the host.
fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) {
if !state.server_available {
return (StatusLightState::Idle, "Offline".to_string());
}
if matches!(state.server_camera_output.as_deref(), Some("uvc")) {
return (StatusLightState::Live, "Enumerated".to_string());
}
if let Some(output) = state.server_camera_output.as_deref() {
return (StatusLightState::Warning, output.to_ascii_uppercase());
}
if state.server_camera.is_none() && state.server_microphone.is_none() {
return (StatusLightState::Caution, "Unknown".to_string());
}
if state.server_camera == Some(false) && state.server_microphone == Some(false) {
return (StatusLightState::Warning, "Missing".to_string());
}
(StatusLightState::Caution, "Partial".to_string())
}
/// Summarize whether the UAC microphone/audio function is advertised by the relay.
fn recovery_uac_health(
state: &LauncherState,
relay_live: bool,
stream: Option<&crate::uplink_telemetry::UpstreamStreamTelemetry>,
) -> (StatusLightState, String) {
if !state.server_available {
return (StatusLightState::Idle, "Offline".to_string());
}
if state.server_microphone == Some(false) {
return (StatusLightState::Warning, "Missing".to_string());
}
if state.server_microphone.is_none() {
return (StatusLightState::Caution, "Unknown".to_string());
}
if !relay_live {
return (StatusLightState::Live, "Ready".to_string());
}
if !state.channels.microphone {
return (StatusLightState::Idle, "Paused".to_string());
}
media_stream_health(stream, MediaStreamKind::Microphone)
}
/// Summarize whether the UVC camera function is advertised with the expected codec.
fn recovery_uvc_health(
state: &LauncherState,
relay_live: bool,
stream: Option<&crate::uplink_telemetry::UpstreamStreamTelemetry>,
) -> (StatusLightState, String) {
if !state.server_available {
return (StatusLightState::Idle, "Offline".to_string());
}
let codec = state
.server_camera_codec
.as_deref()
.map(|value| value.to_ascii_uppercase())
.unwrap_or_else(|| "READY".to_string());
if state.server_camera == Some(false) {
return (StatusLightState::Warning, "Missing".to_string());
}
if state.server_camera.is_none() {
return (StatusLightState::Caution, "Unknown".to_string());
}
if !matches!(state.server_camera_output.as_deref(), Some("uvc")) {
let value = state
.server_camera_output
.as_deref()
.map(|output| format!("{}/{}", output.to_ascii_uppercase(), codec))
.unwrap_or(codec);
return (StatusLightState::Caution, value);
}
if !relay_live {
return (StatusLightState::Live, codec);
}
if !state.channels.camera {
return (StatusLightState::Idle, "Paused".to_string());
}
let (health, label) = media_stream_health(stream, MediaStreamKind::Camera);
if matches!(health, StatusLightState::Live) {
(health, codec)
} else {
(health, label)
}
}
#[derive(Clone, Copy)]
enum MediaStreamKind {
Camera,
Microphone,
}
/// Converts live uplink telemetry into the small, glanceable UAC/UVC chip state.
fn media_stream_health(
stream: Option<&crate::uplink_telemetry::UpstreamStreamTelemetry>,
kind: MediaStreamKind,
) -> (StatusLightState, String) {
let Some(stream) = stream else {
return match kind {
MediaStreamKind::Camera => (StatusLightState::Caution, "No Frames".to_string()),
MediaStreamKind::Microphone => (StatusLightState::Caution, "No Flow".to_string()),
};
};
if !stream.enabled {
return (StatusLightState::Idle, "Paused".to_string());
}
if !stream.last_error.trim().is_empty() {
return (StatusLightState::Warning, "Error".to_string());
}
if !stream.connected {
return match kind {
MediaStreamKind::Camera => (StatusLightState::Caution, "No Frames".to_string()),
MediaStreamKind::Microphone => (StatusLightState::Caution, "No Flow".to_string()),
};
}
if stream.packets_streamed == 0 {
let label = if stream.packets_enqueued > 0 {
"Queued"
} else {
match kind {
MediaStreamKind::Camera => "No Frames",
MediaStreamKind::Microphone => "No Flow",
}
};
return (StatusLightState::Caution, label.to_string());
}
let (delivery_budget_ms, enqueue_budget_ms, queue_pressure, healthy_label) = match kind {
MediaStreamKind::Camera => (250.0, 250.0, 24, "Frames"),
MediaStreamKind::Microphone => (180.0, 120.0, 12, "Flowing"),
};
if stream.latest_delivery_age_ms > delivery_budget_ms
|| stream.latest_enqueue_age_ms > enqueue_budget_ms
{
return (StatusLightState::Caution, "Lagging".to_string());
}
if stream.queue_depth >= queue_pressure {
return (StatusLightState::Caution, "Dropping".to_string());
}
(StatusLightState::Live, healthy_label.to_string())
}
fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState {
if !power.available || !power.enabled {
return StatusLightState::Idle;
}
match power.detected_devices {
0 => StatusLightState::Warning,
1 => StatusLightState::Caution,
_ => StatusLightState::Live,
}
}
fn gpio_detection_detail(detected_devices: u32) -> String {
match detected_devices {
0 => "no eyes detected".to_string(),
1 => "1 eye detected".to_string(),
count => format!("{count} eyes detected"),
}
}
/// Highlights the currently active capture mode so it reads like a segmented control.
fn sync_power_mode_button_styles(widgets: &LauncherWidgets, mode: &str) {
for button in [
&widgets.power_auto_button,
&widgets.power_on_button,
&widgets.power_off_button,
] {
button.remove_css_class("pill-toggle-active");
}
match mode {
"forced-on" => widgets.power_on_button.add_css_class("pill-toggle-active"),
"forced-off" => widgets.power_off_button.add_css_class("pill-toggle-active"),
_ => widgets
.power_auto_button
.add_css_class("pill-toggle-active"),
}
}
/// Reports which local staging checks are active right now.
fn local_test_detail(
camera_running: bool,
microphone_running: bool,
speaker_running: bool,
microphone_replay_running: bool,
) -> String {
let mut active = Vec::new();
if camera_running {
active.push("camera preview");
}
if microphone_running {
active.push("mic monitor");
}
if speaker_running {
active.push("speaker tone");
}
if microphone_replay_running {
active.push("mic replay");
}
if active.is_empty() {
"Local checks are idle. Use Start Preview, Monitor Mic, Replay, or Play Tone before you launch."
.to_string()
} else {
format!(
"Local checks running: {}. Stop them whenever staging is complete.",
active.join(", ")
)
}
}
fn install_popout_drag(window: &gtk::ApplicationWindow, widget: &impl IsA<gtk::Widget>) {
let drag = gtk::GestureClick::new();
drag.set_button(0);
let native = window.clone();
drag.connect_pressed(move |gesture, _press, x, y| {
let Some(device) = gesture.current_event_device() else {
return;
};
let Some(surface) = native.surface() else {
return;
};
let Some(toplevel) = surface.dynamic_cast_ref::<gdk::Toplevel>() else {
return;
};
let timestamp = gesture
.current_event()
.map(|event| event.time())
.unwrap_or(0);
toplevel.begin_move(&device, 1, x, y, timestamp);
});
widget.add_controller(drag);
}
fn apply_popout_window_geometry(
window: &gtk::ApplicationWindow,
root: &gtk::Box,
picture: &gtk::Picture,
size: BreakoutSizeChoice,
display_limit: super::state::PreviewSourceSize,
) {
picture.set_size_request(size.width, size.height);
root.set_size_request(size.width, size.height);
window.set_default_size(size.width, size.height);
if should_cover_display(size, display_limit) {
fullscreen_on_largest_monitor(window);
} else {
window.unfullscreen();
}
}
fn schedule_popout_window_geometry(
window: gtk::ApplicationWindow,
root: gtk::Box,
picture: gtk::Picture,
size: BreakoutSizeChoice,
display_limit: super::state::PreviewSourceSize,
) {
for delay_ms in [0_u64, 25, 150] {
let window = window.clone();
let root = root.clone();
let picture = picture.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(delay_ms), move || {
apply_popout_window_geometry(&window, &root, &picture, size, display_limit);
window.present();
});
}
}
fn fullscreen_on_largest_monitor(window: &gtk::ApplicationWindow) {
let Some(display) = gdk::Display::default() else {
window.fullscreen();
return;
};
let monitors = display.monitors();
let monitor = (0..monitors.n_items())
.filter_map(|idx| monitors.item(idx))
.filter_map(|obj| obj.downcast::<gdk::Monitor>().ok())
.max_by_key(|monitor| {
let geometry = monitor.geometry();
let scale = monitor.scale_factor().max(1);
geometry.width().max(1) as i64
* scale as i64
* geometry.height().max(1) as i64
* scale as i64
});
if let Some(monitor) = monitor.as_ref() {
window.fullscreen_on_monitor(monitor);
} else {
window.fullscreen();
}
}
fn should_cover_display(
size: BreakoutSizeChoice,
display_limit: super::state::PreviewSourceSize,
) -> bool {
matches!(size.preset, super::state::BreakoutSizePreset::FillDisplay)
|| (size.width >= display_limit.width.max(1) as i32
&& size.height >= display_limit.height.max(1) as i32)
}
pub fn present_popout_windows(popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>) {
for handle in popouts.borrow().iter().flatten() {
handle.window.present();
}
}