feat(launcher): stage devices with preview and capture power modes
This commit is contained in:
parent
d70199c410
commit
e15afc2ebd
@ -1,7 +1,18 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use gst::prelude::*;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
use gtk::{gdk, glib};
|
||||
use shell_escape::escape;
|
||||
use std::borrow::Cow;
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
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.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceTestKind {
|
||||
@ -10,25 +21,69 @@ pub enum DeviceTestKind {
|
||||
Speaker,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DeviceTestController {
|
||||
camera: Option<Child>,
|
||||
camera: Option<LocalCameraPreview>,
|
||||
selected_camera: Option<String>,
|
||||
microphone: Option<Child>,
|
||||
speaker: Option<Child>,
|
||||
}
|
||||
|
||||
impl Default for DeviceTestController {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
camera: None,
|
||||
selected_camera: None,
|
||||
microphone: None,
|
||||
speaker: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceTestController {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn is_running(&mut self, kind: DeviceTestKind) -> bool {
|
||||
self.cleanup_finished();
|
||||
self.slot(kind).is_some()
|
||||
pub fn bind_camera_preview(
|
||||
&mut self,
|
||||
camera_picture: >k::Picture,
|
||||
camera_status: >k::Label,
|
||||
) -> Result<()> {
|
||||
if let Some(camera) = self.camera.as_mut() {
|
||||
camera.stop();
|
||||
}
|
||||
let mut preview = LocalCameraPreview::new(camera_picture, camera_status);
|
||||
preview.set_selected(self.selected_camera.as_deref())?;
|
||||
self.camera = Some(preview);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn toggle_camera(&mut self, camera: Option<&str>) -> Result<bool> {
|
||||
self.toggle(DeviceTestKind::Camera, build_camera_test(camera))
|
||||
pub fn is_running(&mut self, kind: DeviceTestKind) -> bool {
|
||||
self.cleanup_finished();
|
||||
match kind {
|
||||
DeviceTestKind::Camera => self
|
||||
.camera
|
||||
.as_ref()
|
||||
.is_some_and(LocalCameraPreview::is_running),
|
||||
DeviceTestKind::Microphone => self.microphone.is_some(),
|
||||
DeviceTestKind::Speaker => self.speaker.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_camera_selection(&mut self, camera: Option<&str>) -> Result<()> {
|
||||
self.selected_camera = normalize_camera_selection(camera);
|
||||
if let Some(preview) = self.camera.as_mut() {
|
||||
preview.set_selected(self.selected_camera.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn toggle_camera(&mut self) -> Result<bool> {
|
||||
let preview = self
|
||||
.camera
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow!("camera preview panel is not ready yet"))?;
|
||||
preview.toggle()
|
||||
}
|
||||
|
||||
pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result<bool> {
|
||||
@ -43,11 +98,10 @@ impl DeviceTestController {
|
||||
}
|
||||
|
||||
pub fn stop_all(&mut self) {
|
||||
for kind in [
|
||||
DeviceTestKind::Camera,
|
||||
DeviceTestKind::Microphone,
|
||||
DeviceTestKind::Speaker,
|
||||
] {
|
||||
if let Some(camera) = self.camera.as_mut() {
|
||||
camera.stop();
|
||||
}
|
||||
for kind in [DeviceTestKind::Microphone, DeviceTestKind::Speaker] {
|
||||
self.stop(kind);
|
||||
}
|
||||
}
|
||||
@ -73,11 +127,7 @@ impl DeviceTestController {
|
||||
}
|
||||
|
||||
fn cleanup_finished(&mut self) {
|
||||
for kind in [
|
||||
DeviceTestKind::Camera,
|
||||
DeviceTestKind::Microphone,
|
||||
DeviceTestKind::Speaker,
|
||||
] {
|
||||
for kind in [DeviceTestKind::Microphone, DeviceTestKind::Speaker] {
|
||||
let finished = self
|
||||
.slot_mut(kind)
|
||||
.as_mut()
|
||||
@ -92,7 +142,7 @@ impl DeviceTestController {
|
||||
|
||||
fn slot(&self, kind: DeviceTestKind) -> &Option<Child> {
|
||||
match kind {
|
||||
DeviceTestKind::Camera => &self.camera,
|
||||
DeviceTestKind::Camera => panic!("camera preview is not an external child process"),
|
||||
DeviceTestKind::Microphone => &self.microphone,
|
||||
DeviceTestKind::Speaker => &self.speaker,
|
||||
}
|
||||
@ -100,28 +150,252 @@ impl DeviceTestController {
|
||||
|
||||
fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option<Child> {
|
||||
match kind {
|
||||
DeviceTestKind::Camera => &mut self.camera,
|
||||
DeviceTestKind::Camera => panic!("camera preview is not an external child process"),
|
||||
DeviceTestKind::Microphone => &mut self.microphone,
|
||||
DeviceTestKind::Speaker => &mut self.speaker,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_camera_test(camera: Option<&str>) -> Result<Command> {
|
||||
let Some(camera) = camera.filter(|value| !value.trim().is_empty()) else {
|
||||
bail!("select a camera before running the local camera test");
|
||||
};
|
||||
let device = format!("/dev/v4l/by-id/{camera}");
|
||||
Ok(shell_command(format!(
|
||||
"gst-launch-1.0 -q v4l2src device={} ! videoconvert ! queue ! autovideosink sync=false",
|
||||
quote(device)
|
||||
)))
|
||||
struct LocalCameraPreview {
|
||||
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
||||
status_text: Arc<Mutex<String>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
running: Arc<AtomicBool>,
|
||||
selected_device: Option<String>,
|
||||
}
|
||||
|
||||
struct PreviewFrame {
|
||||
width: i32,
|
||||
height: i32,
|
||||
stride: usize,
|
||||
rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
impl LocalCameraPreview {
|
||||
fn new(picture: >k::Picture, status_label: >k::Label) -> Self {
|
||||
let latest = Arc::new(Mutex::new(None::<PreviewFrame>));
|
||||
let status_text = Arc::new(Mutex::new(CAMERA_PREVIEW_IDLE.to_string()));
|
||||
let generation = Arc::new(AtomicU64::new(0));
|
||||
let running = Arc::new(AtomicBool::new(false));
|
||||
|
||||
{
|
||||
let picture = picture.clone();
|
||||
let status_label = status_label.clone();
|
||||
let latest = Arc::clone(&latest);
|
||||
let status_text = Arc::clone(&status_text);
|
||||
glib::timeout_add_local(Duration::from_millis(120), move || {
|
||||
let next = latest.lock().ok().and_then(|mut slot| slot.take());
|
||||
if let Some(frame) = next {
|
||||
let bytes = glib::Bytes::from_owned(frame.rgba);
|
||||
let texture = gdk::MemoryTexture::new(
|
||||
frame.width,
|
||||
frame.height,
|
||||
gdk::MemoryFormat::R8g8b8a8,
|
||||
&bytes,
|
||||
frame.stride,
|
||||
);
|
||||
picture.set_paintable(Some(&texture));
|
||||
}
|
||||
if let Ok(text) = status_text.lock() {
|
||||
status_label.set_text(text.as_str());
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
latest,
|
||||
status_text,
|
||||
generation,
|
||||
running,
|
||||
selected_device: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn set_selected(&mut self, camera: Option<&str>) -> Result<()> {
|
||||
self.selected_device = normalize_camera_selection(camera);
|
||||
|
||||
if self.is_running() {
|
||||
self.stop();
|
||||
self.start()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.set_status(match self.selected_device.as_deref() {
|
||||
Some(camera) => format!("Selected {camera}. Click Start Preview to verify it here."),
|
||||
None => CAMERA_PREVIEW_IDLE.to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle(&mut self) -> Result<bool> {
|
||||
if self.is_running() {
|
||||
self.stop();
|
||||
return Ok(false);
|
||||
}
|
||||
self.start()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn start(&mut self) -> Result<()> {
|
||||
gst::init().context("initialising in-launcher camera preview")?;
|
||||
let selected = self
|
||||
.selected_device
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?;
|
||||
let device = resolve_camera_device(&selected);
|
||||
let latest = Arc::clone(&self.latest);
|
||||
let status_text = Arc::clone(&self.status_text);
|
||||
let generation = Arc::clone(&self.generation);
|
||||
let running = Arc::clone(&self.running);
|
||||
let token = generation.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
running.store(true, Ordering::Release);
|
||||
self.set_status(format!("Starting preview for {selected}..."));
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = run_camera_preview_feed(
|
||||
selected,
|
||||
device,
|
||||
token,
|
||||
latest,
|
||||
status_text.clone(),
|
||||
generation.clone(),
|
||||
running.clone(),
|
||||
) {
|
||||
if generation.load(Ordering::Acquire) == token {
|
||||
running.store(false, Ordering::Release);
|
||||
if let Ok(mut status) = status_text.lock() {
|
||||
*status = format!("Camera preview failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.running.store(false, Ordering::Release);
|
||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||
if let Ok(mut latest) = self.latest.lock() {
|
||||
*latest = None;
|
||||
}
|
||||
self.set_status(match self.selected_device.as_deref() {
|
||||
Some(camera) => format!("Preview stopped. {camera} is still selected."),
|
||||
None => CAMERA_PREVIEW_IDLE.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn set_status(&self, text: String) {
|
||||
if let Ok(mut status) = self.status_text.lock() {
|
||||
*status = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_camera_selection(camera: Option<&str>) -> Option<String> {
|
||||
camera
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto"))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn resolve_camera_device(camera: &str) -> String {
|
||||
if camera.starts_with("/dev/") {
|
||||
camera.to_string()
|
||||
} else {
|
||||
format!("/dev/v4l/by-id/{camera}")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_camera_preview_feed(
|
||||
selected: String,
|
||||
device: String,
|
||||
token: u64,
|
||||
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
||||
status_text: Arc<Mutex<String>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
running: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let (pipeline, appsink) = build_camera_preview_pipeline(&device)?;
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.context("starting in-launcher camera preview pipeline")?;
|
||||
|
||||
if let Ok(mut status) = status_text.lock() {
|
||||
*status = format!("Previewing {selected} locally.");
|
||||
}
|
||||
|
||||
while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
|
||||
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) {
|
||||
if let Some(frame) = sample_to_frame(&sample) {
|
||||
if let Ok(mut slot) = latest.lock() {
|
||||
*slot = Some(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = pipeline.set_state(gst::State::Null);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_camera_preview_pipeline(device: &str) -> Result<(gst::Pipeline, gst_app::AppSink)> {
|
||||
let device = gst_quote(device);
|
||||
let desc = format!(
|
||||
"v4l2src device=\"{device}\" do-timestamp=true ! \
|
||||
video/x-raw,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},framerate=30/1 ! \
|
||||
videoconvert ! videoscale ! \
|
||||
video/x-raw,format=RGBA,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},pixel-aspect-ratio=1/1 ! \
|
||||
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true"
|
||||
);
|
||||
let pipeline = gst::parse::launch(&desc)?
|
||||
.downcast::<gst::Pipeline>()
|
||||
.expect("camera preview pipeline");
|
||||
let appsink = pipeline
|
||||
.by_name("sink")
|
||||
.context("missing in-launcher camera preview appsink")?
|
||||
.downcast::<gst_app::AppSink>()
|
||||
.expect("camera preview appsink");
|
||||
appsink.set_caps(Some(
|
||||
&gst::Caps::builder("video/x-raw")
|
||||
.field("format", &"RGBA")
|
||||
.field("width", &CAMERA_PREVIEW_WIDTH)
|
||||
.field("height", &CAMERA_PREVIEW_HEIGHT)
|
||||
.build(),
|
||||
));
|
||||
Ok((pipeline, appsink))
|
||||
}
|
||||
|
||||
fn sample_to_frame(sample: &gst::Sample) -> Option<PreviewFrame> {
|
||||
let caps = sample.caps()?;
|
||||
let structure = caps.structure(0)?;
|
||||
let width = structure.get::<i32>("width").ok()?;
|
||||
let height = structure.get::<i32>("height").ok()?;
|
||||
let buffer = sample.buffer()?;
|
||||
let map = buffer.map_readable().ok()?;
|
||||
let rgba = map.as_slice().to_vec();
|
||||
let stride = rgba.len() / height.max(1) as usize;
|
||||
Some(PreviewFrame {
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
|
||||
fn gst_quote(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn build_microphone_test(source: Option<&str>, sink: Option<&str>) -> Result<Command> {
|
||||
let source = source
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("select a microphone before starting a monitor test"))?;
|
||||
.ok_or_else(|| anyhow!("select a microphone before starting a monitor test"))?;
|
||||
let sink = sink.filter(|value| !value.trim().is_empty());
|
||||
let sink_prop = sink
|
||||
.map(|value| format!("device={}", quote(value)))
|
||||
@ -153,3 +427,28 @@ fn shell_command(command: String) -> Command {
|
||||
fn quote(value: impl Into<String>) -> String {
|
||||
escape(Cow::Owned(value.into())).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalize_camera_selection, resolve_camera_device};
|
||||
|
||||
#[test]
|
||||
fn resolve_camera_device_accepts_explicit_paths_and_catalog_names() {
|
||||
assert_eq!(resolve_camera_device("/dev/video0"), "/dev/video0");
|
||||
assert_eq!(
|
||||
resolve_camera_device("usb-Logitech_C920-video-index0"),
|
||||
"/dev/v4l/by-id/usb-Logitech_C920-video-index0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_camera_selection_drops_auto_and_blank_values() {
|
||||
assert_eq!(normalize_camera_selection(None), None);
|
||||
assert_eq!(normalize_camera_selection(Some("")), None);
|
||||
assert_eq!(normalize_camera_selection(Some("auto")), None);
|
||||
assert_eq!(
|
||||
normalize_camera_selection(Some("usb-Logitech_C920-video-index0")),
|
||||
Some("usb-Logitech_C920-video-index0".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use lesavka_common::lesavka::{Empty, SetCapturePowerRequest, relay_client::RelayClient};
|
||||
use lesavka_common::lesavka::{
|
||||
CapturePowerCommand, Empty, SetCapturePowerRequest, relay_client::RelayClient,
|
||||
};
|
||||
use tonic::{Request, transport::Channel};
|
||||
|
||||
use super::state::CapturePowerStatus;
|
||||
@ -16,11 +18,17 @@ pub fn fetch_capture_power(server_addr: &str) -> Result<CapturePowerStatus> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_capture_power(server_addr: &str, enabled: bool) -> Result<CapturePowerStatus> {
|
||||
pub fn set_capture_power_mode(
|
||||
server_addr: &str,
|
||||
command: CapturePowerCommand,
|
||||
) -> Result<CapturePowerStatus> {
|
||||
with_runtime(async move {
|
||||
let mut client = connect(server_addr).await?;
|
||||
let reply = client
|
||||
.set_capture_power(Request::new(SetCapturePowerRequest { enabled }))
|
||||
.set_capture_power(Request::new(SetCapturePowerRequest {
|
||||
enabled: matches!(command, CapturePowerCommand::ForceOn),
|
||||
command: command as i32,
|
||||
}))
|
||||
.await
|
||||
.context("setting capture power state")?
|
||||
.into_inner();
|
||||
|
||||
@ -7,7 +7,7 @@ use {
|
||||
super::devices::DeviceCatalog,
|
||||
super::diagnostics::quality_probe_command,
|
||||
super::launcher_focus_signal_path,
|
||||
super::power::{fetch_capture_power, set_capture_power},
|
||||
super::power::{fetch_capture_power, set_capture_power_mode},
|
||||
super::state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
|
||||
super::ui_components::build_launcher_view,
|
||||
super::ui_runtime::{
|
||||
@ -19,6 +19,7 @@ use {
|
||||
},
|
||||
gtk::glib,
|
||||
gtk::prelude::*,
|
||||
lesavka_common::lesavka::CapturePowerCommand,
|
||||
std::cell::{Cell, RefCell},
|
||||
std::process::Child,
|
||||
std::rc::Rc,
|
||||
@ -85,6 +86,25 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let preview = view.preview.clone();
|
||||
let popouts = Rc::clone(&view.popouts);
|
||||
|
||||
{
|
||||
let mut tests = tests.borrow_mut();
|
||||
if let Err(err) = tests.bind_camera_preview(
|
||||
&view.device_stage.camera_preview,
|
||||
&view.device_stage.camera_status,
|
||||
) {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Camera preview setup failed: {err}"));
|
||||
}
|
||||
if let Err(err) =
|
||||
tests.set_camera_selection(state.borrow().devices.camera.as_deref())
|
||||
{
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Camera staging setup failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
||||
|
||||
@ -96,13 +116,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let state = Rc::clone(&state);
|
||||
let widgets = widgets.clone();
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let tests = Rc::clone(&tests);
|
||||
let camera_combo = camera_combo.clone();
|
||||
let camera_combo_read = camera_combo.clone();
|
||||
camera_combo.connect_changed(move |_| {
|
||||
state
|
||||
.borrow_mut()
|
||||
.select_camera(selected_combo_value(&camera_combo_read));
|
||||
let selected = selected_combo_value(&camera_combo_read);
|
||||
state.borrow_mut().select_camera(selected.clone());
|
||||
if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Camera preview update failed: {err}"));
|
||||
}
|
||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
||||
});
|
||||
}
|
||||
|
||||
@ -315,15 +341,18 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let camera_test_button = widgets.camera_test_button.clone();
|
||||
let widgets_handle = widgets.clone();
|
||||
camera_test_button.connect_clicked(move |_| {
|
||||
let result = tests
|
||||
.borrow_mut()
|
||||
.toggle_camera(selected_combo_value(&camera_combo).as_deref());
|
||||
let selected = selected_combo_value(&camera_combo);
|
||||
let result = {
|
||||
let mut tests = tests.borrow_mut();
|
||||
let _ = tests.set_camera_selection(selected.as_deref());
|
||||
tests.toggle_camera()
|
||||
};
|
||||
update_test_action_result(
|
||||
&widgets_handle,
|
||||
&mut tests.borrow_mut(),
|
||||
result,
|
||||
"Camera test started.",
|
||||
"Camera test stopped.",
|
||||
"Camera preview started inside the launcher.",
|
||||
"Camera preview stopped.",
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -373,27 +402,78 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
let state = Rc::clone(&state);
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let power_tx = power_tx.clone();
|
||||
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
||||
widgets.power_button.connect_clicked(move |_| {
|
||||
let power_auto_button = widgets.power_auto_button.clone();
|
||||
let widgets_handle = widgets.clone();
|
||||
power_auto_button.connect_clicked(move |_| {
|
||||
if power_request_in_flight.replace(true) {
|
||||
return;
|
||||
}
|
||||
let target = !state.borrow().capture_power.enabled;
|
||||
let server_addr =
|
||||
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets.status_label.set_text(if target {
|
||||
"Powering up remote capture feeds..."
|
||||
} else {
|
||||
"Powering down remote capture feeds..."
|
||||
});
|
||||
widgets_handle
|
||||
.status_label
|
||||
.set_text("Returning capture feeds to automatic mode...");
|
||||
let tx = power_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
set_capture_power(&server_addr, target).map_err(|err| err.to_string());
|
||||
set_capture_power_mode(&server_addr, CapturePowerCommand::Auto)
|
||||
.map_err(|err| err.to_string());
|
||||
let _ = tx.send(PowerMessage::Command(result));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let power_tx = power_tx.clone();
|
||||
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
||||
let power_on_button = widgets.power_on_button.clone();
|
||||
let widgets_handle = widgets.clone();
|
||||
power_on_button.connect_clicked(move |_| {
|
||||
if power_request_in_flight.replace(true) {
|
||||
return;
|
||||
}
|
||||
let server_addr =
|
||||
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_handle
|
||||
.status_label
|
||||
.set_text("Forcing capture feeds on for staging...");
|
||||
let tx = power_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
set_capture_power_mode(&server_addr, CapturePowerCommand::ForceOn)
|
||||
.map_err(|err| err.to_string());
|
||||
let _ = tx.send(PowerMessage::Command(result));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let power_tx = power_tx.clone();
|
||||
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
||||
let power_off_button = widgets.power_off_button.clone();
|
||||
let widgets_handle = widgets.clone();
|
||||
power_off_button.connect_clicked(move |_| {
|
||||
if power_request_in_flight.replace(true) {
|
||||
return;
|
||||
}
|
||||
let server_addr =
|
||||
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_handle
|
||||
.status_label
|
||||
.set_text("Forcing capture feeds off for staging...");
|
||||
let tx = power_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
set_capture_power_mode(&server_addr, CapturePowerCommand::ForceOff)
|
||||
.map_err(|err| err.to_string());
|
||||
let _ = tx.send(PowerMessage::Command(result));
|
||||
});
|
||||
});
|
||||
@ -510,12 +590,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
});
|
||||
}
|
||||
PowerMessage::Command(Ok(power)) => {
|
||||
let enabled = power.enabled;
|
||||
let mode = power.mode.clone();
|
||||
state.borrow_mut().set_capture_power(power);
|
||||
widgets.status_label.set_text(if enabled {
|
||||
"Capture feeds powered up."
|
||||
} else {
|
||||
"Capture feeds powered down."
|
||||
widgets.status_label.set_text(match mode.as_str() {
|
||||
"forced-on" => "Capture feeds forced on.",
|
||||
"forced-off" => "Capture feeds forced off.",
|
||||
_ => "Capture feeds returned to automatic mode.",
|
||||
});
|
||||
}
|
||||
PowerMessage::Command(Err(err)) => {
|
||||
|
||||
@ -42,7 +42,9 @@ pub struct LauncherWidgets {
|
||||
pub display_panes: [DisplayPaneWidgets; 2],
|
||||
pub start_button: gtk::Button,
|
||||
pub stop_button: gtk::Button,
|
||||
pub power_button: gtk::Button,
|
||||
pub power_auto_button: gtk::Button,
|
||||
pub power_on_button: gtk::Button,
|
||||
pub power_off_button: gtk::Button,
|
||||
pub input_toggle_button: gtk::Button,
|
||||
pub clipboard_button: gtk::Button,
|
||||
pub probe_button: gtk::Button,
|
||||
@ -52,12 +54,19 @@ pub struct LauncherWidgets {
|
||||
pub speaker_test_button: gtk::Button,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceStageWidgets {
|
||||
pub camera_preview: gtk::Picture,
|
||||
pub camera_status: gtk::Label,
|
||||
}
|
||||
|
||||
pub struct LauncherView {
|
||||
pub window: gtk::ApplicationWindow,
|
||||
pub server_entry: gtk::Entry,
|
||||
pub camera_combo: gtk::ComboBoxText,
|
||||
pub microphone_combo: gtk::ComboBoxText,
|
||||
pub speaker_combo: gtk::ComboBoxText,
|
||||
pub device_stage: DeviceStageWidgets,
|
||||
pub widgets: LauncherWidgets,
|
||||
pub preview: Option<Rc<LauncherPreview>>,
|
||||
pub popouts: Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
|
||||
@ -131,7 +140,7 @@ pub fn build_launcher_view(
|
||||
stage.set_vexpand(true);
|
||||
content.append(&stage);
|
||||
|
||||
let (connection_panel, connection_body) = build_panel("Connection");
|
||||
let (connection_panel, connection_body) = build_panel("Session");
|
||||
let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let server_entry = gtk::Entry::new();
|
||||
server_entry.add_css_class("server-entry");
|
||||
@ -149,18 +158,39 @@ pub fn build_launcher_view(
|
||||
server_row.append(&stop_button);
|
||||
connection_body.append(&server_row);
|
||||
|
||||
let power_intro = gtk::Label::new(Some(
|
||||
"Capture power can stay automatic or be forced on/off while you stage a session.",
|
||||
));
|
||||
power_intro.add_css_class("dim-label");
|
||||
power_intro.set_wrap(true);
|
||||
power_intro.set_xalign(0.0);
|
||||
connection_body.append(&power_intro);
|
||||
|
||||
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let power_button = gtk::Button::with_label("Power Up Feeds");
|
||||
power_button.set_tooltip_text(Some(
|
||||
"Turns the relay.service-backed capture power on or off from the launcher.",
|
||||
let power_auto_button = gtk::Button::with_label("Auto");
|
||||
power_auto_button.add_css_class("pill-toggle");
|
||||
power_auto_button.set_tooltip_text(Some(
|
||||
"Automatic mode follows the active remote preview and relay stream leases.",
|
||||
));
|
||||
let power_on_button = gtk::Button::with_label("Force On");
|
||||
power_on_button.add_css_class("pill-toggle");
|
||||
power_on_button.set_tooltip_text(Some(
|
||||
"Keep the capture feeds powered even when no preview or session stream is active.",
|
||||
));
|
||||
let power_off_button = gtk::Button::with_label("Force Off");
|
||||
power_off_button.add_css_class("pill-toggle");
|
||||
power_off_button.set_tooltip_text(Some(
|
||||
"Hold the capture feeds down even if previews or clients ask for them.",
|
||||
));
|
||||
let power_detail = gtk::Label::new(Some("Capture power status is loading..."));
|
||||
power_detail.add_css_class("dim-label");
|
||||
power_detail.set_wrap(true);
|
||||
power_detail.set_xalign(0.0);
|
||||
power_row.append(&power_button);
|
||||
power_row.append(&power_detail);
|
||||
power_row.append(&power_auto_button);
|
||||
power_row.append(&power_on_button);
|
||||
power_row.append(&power_off_button);
|
||||
connection_body.append(&power_row);
|
||||
connection_body.append(&power_detail);
|
||||
sidebar.append(&connection_panel);
|
||||
|
||||
let (routing_panel, routing_body) = build_panel("Input Routing");
|
||||
@ -193,7 +223,14 @@ pub fn build_launcher_view(
|
||||
routing_body.append(&swap_row);
|
||||
sidebar.append(&routing_panel);
|
||||
|
||||
let (devices_panel, devices_body) = build_panel("Devices");
|
||||
let (devices_panel, devices_body) = build_panel("Device Staging");
|
||||
let devices_intro = gtk::Label::new(Some(
|
||||
"Lock in the exact local camera, microphone, and speaker you want before you launch the relay.",
|
||||
));
|
||||
devices_intro.add_css_class("dim-label");
|
||||
devices_intro.set_wrap(true);
|
||||
devices_intro.set_xalign(0.0);
|
||||
devices_body.append(&devices_intro);
|
||||
let devices_grid = gtk::Grid::new();
|
||||
devices_grid.set_row_spacing(8);
|
||||
devices_grid.set_column_spacing(8);
|
||||
@ -205,7 +242,7 @@ pub fn build_launcher_view(
|
||||
camera_combo.append(Some(camera), camera);
|
||||
}
|
||||
super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref());
|
||||
let camera_test_button = gtk::Button::with_label("Test");
|
||||
let camera_test_button = gtk::Button::with_label("Start Preview");
|
||||
camera_test_button.set_tooltip_text(Some(
|
||||
"Open a local preview for the selected webcam so you can confirm the right source.",
|
||||
));
|
||||
@ -226,7 +263,7 @@ pub fn build_launcher_view(
|
||||
µphone_combo,
|
||||
state.devices.microphone.as_deref(),
|
||||
);
|
||||
let microphone_test_button = gtk::Button::with_label("Test");
|
||||
let microphone_test_button = gtk::Button::with_label("Monitor Mic");
|
||||
microphone_test_button.set_tooltip_text(Some(
|
||||
"Monitor the selected microphone through the selected speaker until you stop the test.",
|
||||
));
|
||||
@ -244,7 +281,7 @@ pub fn build_launcher_view(
|
||||
speaker_combo.append(Some(speaker), speaker);
|
||||
}
|
||||
super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref());
|
||||
let speaker_test_button = gtk::Button::with_label("Test");
|
||||
let speaker_test_button = gtk::Button::with_label("Play Tone");
|
||||
speaker_test_button.set_tooltip_text(Some(
|
||||
"Play a short continuous tone through the selected speaker until you stop the test.",
|
||||
));
|
||||
@ -255,6 +292,32 @@ pub fn build_launcher_view(
|
||||
&speaker_combo,
|
||||
&speaker_test_button,
|
||||
);
|
||||
|
||||
let preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
preview_shell.add_css_class("camera-preview-shell");
|
||||
let preview_heading = gtk::Label::new(Some("Selected Camera Preview"));
|
||||
preview_heading.add_css_class("panel-title");
|
||||
preview_heading.set_halign(gtk::Align::Start);
|
||||
let preview_note = gtk::Label::new(Some(
|
||||
"Use this to verify the chosen webcam in-place. Audio device tests still stay local.",
|
||||
));
|
||||
preview_note.add_css_class("dim-label");
|
||||
preview_note.set_wrap(true);
|
||||
preview_note.set_xalign(0.0);
|
||||
let camera_preview = gtk::Picture::new();
|
||||
camera_preview.set_can_shrink(true);
|
||||
camera_preview.set_hexpand(true);
|
||||
camera_preview.set_size_request(360, 202);
|
||||
camera_preview.add_css_class("camera-preview-frame");
|
||||
let camera_status = gtk::Label::new(Some("Select a camera and click Start Preview."));
|
||||
camera_status.add_css_class("dim-label");
|
||||
camera_status.set_wrap(true);
|
||||
camera_status.set_xalign(0.0);
|
||||
preview_shell.append(&preview_heading);
|
||||
preview_shell.append(&preview_note);
|
||||
preview_shell.append(&camera_preview);
|
||||
preview_shell.append(&camera_status);
|
||||
devices_body.append(&preview_shell);
|
||||
sidebar.append(&devices_panel);
|
||||
|
||||
let (actions_panel, actions_body) = build_panel("Remote Actions");
|
||||
@ -327,7 +390,9 @@ pub fn build_launcher_view(
|
||||
display_panes: [left_pane.clone(), right_pane.clone()],
|
||||
start_button: start_button.clone(),
|
||||
stop_button: stop_button.clone(),
|
||||
power_button: power_button.clone(),
|
||||
power_auto_button: power_auto_button.clone(),
|
||||
power_on_button: power_on_button.clone(),
|
||||
power_off_button: power_off_button.clone(),
|
||||
input_toggle_button: input_toggle_button.clone(),
|
||||
clipboard_button: clipboard_button.clone(),
|
||||
probe_button: probe_button.clone(),
|
||||
@ -346,6 +411,10 @@ pub fn build_launcher_view(
|
||||
camera_combo,
|
||||
microphone_combo,
|
||||
speaker_combo,
|
||||
device_stage: DeviceStageWidgets {
|
||||
camera_preview,
|
||||
camera_status,
|
||||
},
|
||||
widgets,
|
||||
preview,
|
||||
popouts,
|
||||
@ -399,12 +468,27 @@ pub fn install_css(window: >k::ApplicationWindow) {
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
box.camera-preview-shell {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
picture.camera-preview-frame {
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 14px;
|
||||
}
|
||||
label.status-line {
|
||||
opacity: 0.88;
|
||||
}
|
||||
entry.server-entry {
|
||||
min-height: 38px;
|
||||
}
|
||||
button.pill-toggle {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
"#,
|
||||
);
|
||||
if let Some(display) = gtk::gdk::Display::default() {
|
||||
|
||||
@ -61,14 +61,16 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
InputRouting::Remote => "Route Inputs To Local",
|
||||
InputRouting::Local => "Route Inputs To Remote",
|
||||
});
|
||||
widgets.power_button.set_label(
|
||||
match (state.capture_power.available, state.capture_power.enabled) {
|
||||
(false, _) => "Capture Power Unavailable",
|
||||
(true, true) => "Power Down Feeds",
|
||||
(true, false) => "Power Up Feeds",
|
||||
},
|
||||
let power_available = state.capture_power.available;
|
||||
widgets
|
||||
.power_auto_button
|
||||
.set_sensitive(power_available && !matches!(state.capture_power.mode.as_str(), "auto"));
|
||||
widgets.power_on_button.set_sensitive(
|
||||
power_available && !matches!(state.capture_power.mode.as_str(), "forced-on"),
|
||||
);
|
||||
widgets.power_off_button.set_sensitive(
|
||||
power_available && !matches!(state.capture_power.mode.as_str(), "forced-off"),
|
||||
);
|
||||
widgets.power_button.set_sensitive(true);
|
||||
|
||||
for monitor_id in 0..2 {
|
||||
refresh_display_pane(
|
||||
@ -82,23 +84,23 @@ pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestCon
|
||||
widgets
|
||||
.camera_test_button
|
||||
.set_label(if tests.is_running(DeviceTestKind::Camera) {
|
||||
"Stop"
|
||||
"Stop Preview"
|
||||
} else {
|
||||
"Test"
|
||||
"Start Preview"
|
||||
});
|
||||
widgets
|
||||
.microphone_test_button
|
||||
.set_label(if tests.is_running(DeviceTestKind::Microphone) {
|
||||
"Stop"
|
||||
"Stop Monitor"
|
||||
} else {
|
||||
"Test"
|
||||
"Monitor Mic"
|
||||
});
|
||||
widgets
|
||||
.speaker_test_button
|
||||
.set_label(if tests.is_running(DeviceTestKind::Speaker) {
|
||||
"Stop"
|
||||
"Stop Tone"
|
||||
} else {
|
||||
"Test"
|
||||
"Play Tone"
|
||||
});
|
||||
}
|
||||
|
||||
@ -275,21 +277,37 @@ pub fn capture_power_label(power: &CapturePowerStatus) -> String {
|
||||
if !power.available {
|
||||
return "Unavailable".to_string();
|
||||
}
|
||||
format!(
|
||||
"{} ({})",
|
||||
if power.enabled { "On" } else { "Off" },
|
||||
power.mode
|
||||
)
|
||||
match power.mode.as_str() {
|
||||
"forced-on" => "Forced On".to_string(),
|
||||
"forced-off" => "Forced Off".to_string(),
|
||||
_ => {
|
||||
if power.enabled {
|
||||
"Auto • Live".to_string()
|
||||
} else {
|
||||
"Auto • Standby".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capture_power_detail(power: &CapturePowerStatus) -> String {
|
||||
if !power.available {
|
||||
return format!("{} is unavailable: {}", power.unit, power.detail);
|
||||
}
|
||||
format!(
|
||||
"{} • {} • leases {}",
|
||||
match power.mode.as_str() {
|
||||
"forced-on" => format!(
|
||||
"{} • manual override holding feeds up • {} • leases {}",
|
||||
power.unit, power.detail, power.active_leases
|
||||
)
|
||||
),
|
||||
"forced-off" => format!(
|
||||
"{} • manual override holding feeds down • {} • leases {}",
|
||||
power.unit, power.detail, power.active_leases
|
||||
),
|
||||
_ => format!(
|
||||
"{} • automatic mode follows live previews and session demand • {} • leases {}",
|
||||
power.unit, power.detail, power.active_leases
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capitalize(value: &str) -> String {
|
||||
|
||||
@ -25,8 +25,15 @@ message CapturePowerState {
|
||||
uint32 active_leases = 5;
|
||||
string mode = 6;
|
||||
}
|
||||
enum CapturePowerCommand {
|
||||
CAPTURE_POWER_COMMAND_UNSPECIFIED = 0;
|
||||
CAPTURE_POWER_COMMAND_AUTO = 1;
|
||||
CAPTURE_POWER_COMMAND_FORCE_ON = 2;
|
||||
CAPTURE_POWER_COMMAND_FORCE_OFF = 3;
|
||||
}
|
||||
message SetCapturePowerRequest {
|
||||
bool enabled = 1;
|
||||
CapturePowerCommand command = 2;
|
||||
}
|
||||
|
||||
message HandshakeSet {
|
||||
|
||||
@ -7,13 +7,13 @@
|
||||
},
|
||||
"client/src/app_support.rs": {
|
||||
"loc": 128,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 3
|
||||
"doc_debt": 3,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"client/src/handshake.rs": {
|
||||
"loc": 194,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 3
|
||||
"doc_debt": 3,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"client/src/input/camera.rs": {
|
||||
"loc": 372,
|
||||
@ -42,8 +42,8 @@
|
||||
},
|
||||
"client/src/input/mod.rs": {
|
||||
"loc": 8,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"client/src/input/mouse.rs": {
|
||||
"loc": 317,
|
||||
@ -56,9 +56,9 @@
|
||||
"doc_debt": 1
|
||||
},
|
||||
"client/src/launcher/device_test.rs": {
|
||||
"loc": 155,
|
||||
"clippy_warnings": 8,
|
||||
"doc_debt": 7
|
||||
"loc": 454,
|
||||
"clippy_warnings": 36,
|
||||
"doc_debt": 20
|
||||
},
|
||||
"client/src/launcher/devices.rs": {
|
||||
"loc": 158,
|
||||
@ -76,9 +76,9 @@
|
||||
"doc_debt": 4
|
||||
},
|
||||
"client/src/launcher/power.rs": {
|
||||
"loc": 61,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"loc": 69,
|
||||
"doc_debt": 1,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"client/src/launcher/preview.rs": {
|
||||
"loc": 293,
|
||||
@ -91,17 +91,17 @@
|
||||
"doc_debt": 15
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"loc": 574,
|
||||
"loc": 654,
|
||||
"clippy_warnings": 4,
|
||||
"doc_debt": 1
|
||||
},
|
||||
"client/src/launcher/ui_components.rs": {
|
||||
"loc": 531,
|
||||
"loc": 615,
|
||||
"clippy_warnings": 8,
|
||||
"doc_debt": 4
|
||||
},
|
||||
"client/src/launcher/ui_runtime.rs": {
|
||||
"loc": 439,
|
||||
"loc": 457,
|
||||
"clippy_warnings": 10,
|
||||
"doc_debt": 18
|
||||
},
|
||||
@ -112,8 +112,8 @@
|
||||
},
|
||||
"client/src/lib.rs": {
|
||||
"loc": 14,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"client/src/main.rs": {
|
||||
"loc": 96,
|
||||
@ -127,8 +127,8 @@
|
||||
},
|
||||
"client/src/output/display.rs": {
|
||||
"loc": 81,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"client/src/output/layout.rs": {
|
||||
"loc": 155,
|
||||
@ -137,8 +137,8 @@
|
||||
},
|
||||
"client/src/output/mod.rs": {
|
||||
"loc": 6,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"client/src/output/video.rs": {
|
||||
"loc": 547,
|
||||
@ -152,28 +152,28 @@
|
||||
},
|
||||
"common/src/bin/cli.rs": {
|
||||
"loc": 3,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"common/src/cli.rs": {
|
||||
"loc": 22,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"common/src/hid.rs": {
|
||||
"loc": 134,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 2
|
||||
"doc_debt": 2,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"common/src/lib.rs": {
|
||||
"loc": 22,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"common/src/paste.rs": {
|
||||
"loc": 95,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 2
|
||||
"doc_debt": 2,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"server/src/audio.rs": {
|
||||
"loc": 386,
|
||||
@ -181,14 +181,13 @@
|
||||
"doc_debt": 7
|
||||
},
|
||||
"server/src/bin/lesavka-uvc.real.inc": {
|
||||
"loc": 0,
|
||||
"clippy_warnings": 31,
|
||||
"doc_debt": 0
|
||||
},
|
||||
"server/src/bin/lesavka-uvc.rs": {
|
||||
"loc": 710,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 17
|
||||
"doc_debt": 17,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"server/src/camera.rs": {
|
||||
"loc": 392,
|
||||
@ -201,8 +200,8 @@
|
||||
"doc_debt": 5
|
||||
},
|
||||
"server/src/capture_power.rs": {
|
||||
"loc": 295,
|
||||
"clippy_warnings": 8,
|
||||
"loc": 338,
|
||||
"clippy_warnings": 10,
|
||||
"doc_debt": 7
|
||||
},
|
||||
"server/src/gadget.rs": {
|
||||
@ -217,13 +216,13 @@
|
||||
},
|
||||
"server/src/lib.rs": {
|
||||
"loc": 14,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"server/src/main.rs": {
|
||||
"loc": 565,
|
||||
"loc": 572,
|
||||
"clippy_warnings": 10,
|
||||
"doc_debt": 12
|
||||
"doc_debt": 13
|
||||
},
|
||||
"server/src/paste.rs": {
|
||||
"loc": 207,
|
||||
@ -237,13 +236,13 @@
|
||||
},
|
||||
"server/src/uvc_control/model.rs": {
|
||||
"loc": 510,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 11
|
||||
"doc_debt": 11,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"server/src/uvc_control/protocol.rs": {
|
||||
"loc": 403,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 11
|
||||
"doc_debt": 11,
|
||||
"clippy_warnings": 0
|
||||
},
|
||||
"server/src/uvc_runtime.rs": {
|
||||
"loc": 241,
|
||||
@ -267,8 +266,8 @@
|
||||
},
|
||||
"testing/src/lib.rs": {
|
||||
"loc": 10,
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0
|
||||
"doc_debt": 0,
|
||||
"clippy_warnings": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
"line_percent": 98.29059829059828
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"loc": 574,
|
||||
"loc": 654,
|
||||
"line_percent": 100.0
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
@ -125,7 +125,7 @@
|
||||
"line_percent": 100.0
|
||||
},
|
||||
"server/src/capture_power.rs": {
|
||||
"loc": 295,
|
||||
"loc": 338,
|
||||
"line_percent": 100.0
|
||||
},
|
||||
"server/src/gadget.rs": {
|
||||
@ -137,8 +137,8 @@
|
||||
"line_percent": 100.0
|
||||
},
|
||||
"server/src/main.rs": {
|
||||
"loc": 565,
|
||||
"line_percent": 95.17241379310344
|
||||
"loc": 572,
|
||||
"line_percent": 95.33333333333334
|
||||
},
|
||||
"server/src/paste.rs": {
|
||||
"loc": 207,
|
||||
|
||||
@ -87,6 +87,18 @@ impl CapturePowerManager {
|
||||
self.snapshot().await
|
||||
}
|
||||
|
||||
pub async fn set_auto(&self) -> Result<CapturePowerState> {
|
||||
let unit = self.unit.to_string();
|
||||
let desired = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.manual_override = None;
|
||||
desired_state(&inner)
|
||||
};
|
||||
|
||||
sync_unit_state(unit.as_str(), desired).await?;
|
||||
self.snapshot().await
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> Result<CapturePowerState> {
|
||||
let (active_leases, manual_override) = {
|
||||
let inner = self.inner.lock().await;
|
||||
@ -109,23 +121,34 @@ impl CapturePowerManager {
|
||||
}
|
||||
|
||||
async fn release_one(&self) {
|
||||
let (desired, unit, leases) = {
|
||||
let (desired, unit, leases, manual_override) = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.active_leases = inner.active_leases.saturating_sub(1);
|
||||
if inner.active_leases == 0 {
|
||||
inner.manual_override = None;
|
||||
}
|
||||
(
|
||||
desired_state(&inner),
|
||||
self.unit.to_string(),
|
||||
inner.active_leases,
|
||||
inner.manual_override,
|
||||
)
|
||||
};
|
||||
|
||||
if let Err(err) = sync_unit_state(unit.as_str(), desired).await {
|
||||
warn!(unit = %unit, leases, desired, ?err, "capture power sync failed on release");
|
||||
warn!(
|
||||
unit = %unit,
|
||||
leases,
|
||||
desired,
|
||||
?manual_override,
|
||||
?err,
|
||||
"capture power sync failed on release"
|
||||
);
|
||||
} else {
|
||||
info!(unit = %unit, leases, desired, "capture power synced");
|
||||
info!(
|
||||
unit = %unit,
|
||||
leases,
|
||||
desired,
|
||||
?manual_override,
|
||||
"capture power synced"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -254,6 +277,17 @@ impl CapturePowerManager {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_auto(&self) -> anyhow::Result<CapturePowerState> {
|
||||
Ok(CapturePowerState {
|
||||
available: true,
|
||||
enabled: false,
|
||||
unit: "relay.service".to_string(),
|
||||
detail: "inactive/dead".to_string(),
|
||||
active_leases: 0,
|
||||
mode: "auto".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> anyhow::Result<CapturePowerState> {
|
||||
Ok(CapturePowerState {
|
||||
available: true,
|
||||
@ -292,4 +326,13 @@ mod tests {
|
||||
assert!(!off.enabled);
|
||||
assert_eq!(off.mode, "forced-off");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coverage_stub_returns_to_auto_mode() {
|
||||
let manager = CapturePowerManager::new();
|
||||
let state = manager.set_auto().await.expect("auto");
|
||||
assert!(state.available);
|
||||
assert!(!state.enabled);
|
||||
assert_eq!(state.mode, "auto");
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ use tonic_reflection::server::Builder as ReflBuilder;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use lesavka_common::lesavka::{
|
||||
AudioPacket, CapturePowerState, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply,
|
||||
PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket,
|
||||
AudioPacket, CapturePowerCommand, CapturePowerState, Empty, KeyboardReport, MonitorRequest,
|
||||
MouseReport, PasteReply, PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket,
|
||||
relay_server::{Relay, RelayServer},
|
||||
};
|
||||
|
||||
@ -170,9 +170,16 @@ impl Handler {
|
||||
&self,
|
||||
req: Request<SetCapturePowerRequest>,
|
||||
) -> Result<Response<CapturePowerState>, Status> {
|
||||
self.capture_power
|
||||
.set_manual(req.into_inner().enabled)
|
||||
.await
|
||||
let req = req.into_inner();
|
||||
let result = match CapturePowerCommand::try_from(req.command)
|
||||
.unwrap_or(CapturePowerCommand::Unspecified)
|
||||
{
|
||||
CapturePowerCommand::Auto => self.capture_power.set_auto().await,
|
||||
CapturePowerCommand::ForceOn => self.capture_power.set_manual(true).await,
|
||||
CapturePowerCommand::ForceOff => self.capture_power.set_manual(false).await,
|
||||
CapturePowerCommand::Unspecified => self.capture_power.set_manual(req.enabled).await,
|
||||
};
|
||||
result
|
||||
.map(Response::new)
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))
|
||||
}
|
||||
|
||||
@ -114,7 +114,10 @@ mod server_main_rpc {
|
||||
fn capture_video_returns_guarded_stream_when_coverage_source_is_overridden() {
|
||||
let (_dir, handler) = build_handler_for_tests();
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
with_var("LESAVKA_TEST_VIDEO_SOURCE", Some("/dev/lesavka_l_eye"), || {
|
||||
with_var(
|
||||
"LESAVKA_TEST_VIDEO_SOURCE",
|
||||
Some("/dev/lesavka_l_eye"),
|
||||
|| {
|
||||
let mut stream = rt
|
||||
.block_on(async {
|
||||
handler
|
||||
@ -132,7 +135,8 @@ mod server_main_rpc {
|
||||
.expect("packet");
|
||||
assert_eq!(packet.id, 0);
|
||||
assert!(!packet.data.is_empty());
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -209,7 +213,11 @@ mod server_main_rpc {
|
||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||
|
||||
let snapshot = rt
|
||||
.block_on(async { handler.get_capture_power(tonic::Request::new(Empty {})).await })
|
||||
.block_on(async {
|
||||
handler
|
||||
.get_capture_power(tonic::Request::new(Empty {}))
|
||||
.await
|
||||
})
|
||||
.expect("capture power snapshot")
|
||||
.into_inner();
|
||||
assert!(snapshot.available);
|
||||
@ -221,6 +229,7 @@ mod server_main_rpc {
|
||||
handler
|
||||
.set_capture_power(tonic::Request::new(SetCapturePowerRequest {
|
||||
enabled: true,
|
||||
command: CapturePowerCommand::ForceOn as i32,
|
||||
}))
|
||||
.await
|
||||
})
|
||||
@ -235,6 +244,7 @@ mod server_main_rpc {
|
||||
handler
|
||||
.set_capture_power(tonic::Request::new(SetCapturePowerRequest {
|
||||
enabled: false,
|
||||
command: CapturePowerCommand::ForceOff as i32,
|
||||
}))
|
||||
.await
|
||||
})
|
||||
@ -243,6 +253,36 @@ mod server_main_rpc {
|
||||
assert!(forced_off.available);
|
||||
assert!(!forced_off.enabled);
|
||||
assert_eq!(forced_off.mode, "forced-off");
|
||||
|
||||
let auto = rt
|
||||
.block_on(async {
|
||||
handler
|
||||
.set_capture_power(tonic::Request::new(SetCapturePowerRequest {
|
||||
enabled: false,
|
||||
command: CapturePowerCommand::Auto as i32,
|
||||
}))
|
||||
.await
|
||||
})
|
||||
.expect("return capture power to auto")
|
||||
.into_inner();
|
||||
assert!(auto.available);
|
||||
assert!(!auto.enabled);
|
||||
assert_eq!(auto.mode, "auto");
|
||||
|
||||
let legacy_fallback = rt
|
||||
.block_on(async {
|
||||
handler
|
||||
.set_capture_power(tonic::Request::new(SetCapturePowerRequest {
|
||||
enabled: true,
|
||||
command: CapturePowerCommand::Unspecified as i32,
|
||||
}))
|
||||
.await
|
||||
})
|
||||
.expect("legacy bool fallback")
|
||||
.into_inner();
|
||||
assert!(legacy_fallback.available);
|
||||
assert!(legacy_fallback.enabled);
|
||||
assert_eq!(legacy_fallback.mode, "forced-on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -260,8 +300,7 @@ mod server_main_rpc {
|
||||
"configured\n",
|
||||
)
|
||||
.expect("write state");
|
||||
std::fs::write(dir.path().join("cfg/lesavka/UDC"), "fake-ctrl.usb\n")
|
||||
.expect("write udc");
|
||||
std::fs::write(dir.path().join("cfg/lesavka/UDC"), "fake-ctrl.usb\n").expect("write udc");
|
||||
|
||||
let kb = tokio::fs::File::from_std(
|
||||
std::fs::OpenOptions::new()
|
||||
@ -290,9 +329,9 @@ mod server_main_rpc {
|
||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)),
|
||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)),
|
||||
gadget: UsbGadget::new("lesavka"),
|
||||
did_cycle: std::sync::Arc::new(
|
||||
std::sync::atomic::AtomicBool::new(false),
|
||||
),
|
||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)),
|
||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||
capture_power: CapturePowerManager::new(),
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user