428 lines
14 KiB
Rust
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: >k::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: >k::ApplicationWindow,
|
|
root: >k::Box,
|
|
picture: >k::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: >k::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();
|
|
}
|
|
}
|