feat(launcher): add webcam quality controls
This commit is contained in:
parent
b322396739
commit
3b112996dd
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.11.48"
|
version = "0.12.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -500,6 +500,7 @@ impl LesavkaClientApp {
|
|||||||
async fn audio_loop(ep: Channel, out: AudioOut) {
|
async fn audio_loop(ep: Channel, out: AudioOut) {
|
||||||
let mut consecutive_source_failures = 0_u32;
|
let mut consecutive_source_failures = 0_u32;
|
||||||
let mut last_usb_recovery_at: Option<Instant> = None;
|
let mut last_usb_recovery_at: Option<Instant> = None;
|
||||||
|
let mut delay = Duration::from_secs(1);
|
||||||
loop {
|
loop {
|
||||||
let mut cli = RelayClient::new(ep.clone());
|
let mut cli = RelayClient::new(ep.clone());
|
||||||
let req = MonitorRequest {
|
let req = MonitorRequest {
|
||||||
@ -515,6 +516,7 @@ impl LesavkaClientApp {
|
|||||||
tracing::info!("🔊 audio stream opened");
|
tracing::info!("🔊 audio stream opened");
|
||||||
let mut packet_count: u64 = 0;
|
let mut packet_count: u64 = 0;
|
||||||
let mut warned_no_packets = false;
|
let mut warned_no_packets = false;
|
||||||
|
delay = Duration::from_secs(1);
|
||||||
loop {
|
loop {
|
||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
Duration::from_secs(1),
|
Duration::from_secs(1),
|
||||||
@ -533,9 +535,13 @@ impl LesavkaClientApp {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
out.push(pkt);
|
out.push(pkt);
|
||||||
|
consecutive_source_failures = 0;
|
||||||
}
|
}
|
||||||
Ok(Ok(None)) => {
|
Ok(Ok(None)) => {
|
||||||
tracing::warn!(packets = packet_count, "⚠️🔊 audio stream ended");
|
tracing::warn!(packets = packet_count, "⚠️🔊 audio stream ended");
|
||||||
|
if packet_count == 0 {
|
||||||
|
delay = app_support::next_delay(delay);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
@ -548,6 +554,7 @@ impl LesavkaClientApp {
|
|||||||
&message,
|
&message,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
delay = app_support::next_delay(delay);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -571,9 +578,10 @@ impl LesavkaClientApp {
|
|||||||
&message,
|
&message,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
delay = app_support::next_delay(delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -96,11 +96,9 @@ impl CameraCapture {
|
|||||||
|cfg| matches!(cfg.codec, CameraCodec::Mjpeg),
|
|cfg| matches!(cfg.codec, CameraCodec::Mjpeg),
|
||||||
);
|
);
|
||||||
let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100);
|
let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100);
|
||||||
let width = cfg.map_or_else(|| env_u32("LESAVKA_CAM_WIDTH", 1280), |cfg| cfg.width);
|
let width = env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width));
|
||||||
let height = cfg.map_or_else(|| env_u32("LESAVKA_CAM_HEIGHT", 720), |cfg| cfg.height);
|
let height = env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height));
|
||||||
let fps = cfg
|
let fps = env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1);
|
||||||
.map_or_else(|| env_u32("LESAVKA_CAM_FPS", 25), |cfg| cfg.fps)
|
|
||||||
.max(1);
|
|
||||||
let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
|
let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
|
||||||
let source_profile = camera_source_profile(allow_mjpg_source);
|
let source_profile = camera_source_profile(allow_mjpg_source);
|
||||||
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
|
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
|
||||||
|
|||||||
@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::devices::CameraMode;
|
||||||
|
|
||||||
const CAMERA_PREVIEW_WIDTH: i32 = 128;
|
const CAMERA_PREVIEW_WIDTH: i32 = 128;
|
||||||
const CAMERA_PREVIEW_HEIGHT: i32 = 72;
|
const CAMERA_PREVIEW_HEIGHT: i32 = 72;
|
||||||
const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview.";
|
const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview.";
|
||||||
@ -36,6 +38,7 @@ pub enum DeviceTestKind {
|
|||||||
pub struct DeviceTestController {
|
pub struct DeviceTestController {
|
||||||
camera: Option<LocalCameraPreview>,
|
camera: Option<LocalCameraPreview>,
|
||||||
selected_camera: Option<String>,
|
selected_camera: Option<String>,
|
||||||
|
selected_camera_mode: Option<CameraMode>,
|
||||||
microphone: Option<LocalMicrophoneMonitor>,
|
microphone: Option<LocalMicrophoneMonitor>,
|
||||||
microphone_probe: Option<LocalMicrophoneLevelProbe>,
|
microphone_probe: Option<LocalMicrophoneLevelProbe>,
|
||||||
speaker: Option<Child>,
|
speaker: Option<Child>,
|
||||||
@ -49,6 +52,7 @@ impl Default for DeviceTestController {
|
|||||||
Self {
|
Self {
|
||||||
camera: None,
|
camera: None,
|
||||||
selected_camera: None,
|
selected_camera: None,
|
||||||
|
selected_camera_mode: None,
|
||||||
microphone: None,
|
microphone: None,
|
||||||
microphone_probe: None,
|
microphone_probe: None,
|
||||||
speaker: None,
|
speaker: None,
|
||||||
@ -74,6 +78,7 @@ impl DeviceTestController {
|
|||||||
}
|
}
|
||||||
let mut preview = LocalCameraPreview::new(camera_picture, camera_status);
|
let mut preview = LocalCameraPreview::new(camera_picture, camera_status);
|
||||||
preview.set_selected(self.selected_camera.as_deref())?;
|
preview.set_selected(self.selected_camera.as_deref())?;
|
||||||
|
preview.set_selected_mode(self.selected_camera_mode)?;
|
||||||
self.camera = Some(preview);
|
self.camera = Some(preview);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -107,6 +112,14 @@ impl DeviceTestController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_camera_quality(&mut self, mode: Option<CameraMode>) -> Result<()> {
|
||||||
|
self.selected_camera_mode = mode;
|
||||||
|
if let Some(preview) = self.camera.as_mut() {
|
||||||
|
preview.set_selected_mode(mode)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_camera(&mut self) -> Result<bool> {
|
pub fn toggle_camera(&mut self) -> Result<bool> {
|
||||||
let preview = self
|
let preview = self
|
||||||
.camera
|
.camera
|
||||||
@ -361,6 +374,7 @@ struct LocalCameraPreview {
|
|||||||
generation: Arc<AtomicU64>,
|
generation: Arc<AtomicU64>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
selected_device: Option<String>,
|
selected_device: Option<String>,
|
||||||
|
selected_mode: Option<CameraMode>,
|
||||||
relay_preview_path: Option<PathBuf>,
|
relay_preview_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -422,6 +436,7 @@ impl LocalCameraPreview {
|
|||||||
generation,
|
generation,
|
||||||
running,
|
running,
|
||||||
selected_device: None,
|
selected_device: None,
|
||||||
|
selected_mode: None,
|
||||||
relay_preview_path: None,
|
relay_preview_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -456,6 +471,27 @@ impl LocalCameraPreview {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_selected_mode(&mut self, mode: Option<CameraMode>) -> Result<()> {
|
||||||
|
self.selected_mode = mode;
|
||||||
|
|
||||||
|
if self.is_device_preview_running() {
|
||||||
|
self.stop();
|
||||||
|
self.start()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(camera) = self.selected_device.as_deref() {
|
||||||
|
let quality = self
|
||||||
|
.selected_mode
|
||||||
|
.map(CameraMode::short_label)
|
||||||
|
.unwrap_or_else(|| "default quality".to_string());
|
||||||
|
self.set_status(format!(
|
||||||
|
"Selected {camera} at {quality}. Start Preview to confirm webcam framing here before you launch the relay."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn toggle(&mut self) -> Result<bool> {
|
fn toggle(&mut self) -> Result<bool> {
|
||||||
if self.is_running() {
|
if self.is_running() {
|
||||||
self.stop();
|
self.stop();
|
||||||
@ -472,6 +508,7 @@ impl LocalCameraPreview {
|
|||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?;
|
.ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?;
|
||||||
self.relay_preview_path = None;
|
self.relay_preview_path = None;
|
||||||
|
let mode = self.selected_mode;
|
||||||
let device = resolve_camera_device(&selected);
|
let device = resolve_camera_device(&selected);
|
||||||
let latest = Arc::clone(&self.latest);
|
let latest = Arc::clone(&self.latest);
|
||||||
let status_text = Arc::clone(&self.status_text);
|
let status_text = Arc::clone(&self.status_text);
|
||||||
@ -485,6 +522,7 @@ impl LocalCameraPreview {
|
|||||||
if let Err(err) = run_camera_preview_feed(
|
if let Err(err) = run_camera_preview_feed(
|
||||||
selected,
|
selected,
|
||||||
device,
|
device,
|
||||||
|
mode,
|
||||||
token,
|
token,
|
||||||
latest,
|
latest,
|
||||||
status_text.clone(),
|
status_text.clone(),
|
||||||
@ -551,8 +589,12 @@ impl LocalCameraPreview {
|
|||||||
} else {
|
} else {
|
||||||
match self.selected_device.as_deref() {
|
match self.selected_device.as_deref() {
|
||||||
Some(camera) => {
|
Some(camera) => {
|
||||||
|
let quality = self
|
||||||
|
.selected_mode
|
||||||
|
.map(CameraMode::short_label)
|
||||||
|
.unwrap_or_else(|| "default quality".to_string());
|
||||||
format!(
|
format!(
|
||||||
"Local preview stopped. {camera} stays selected for the next relay launch."
|
"Local preview stopped. {camera} at {quality} stays selected for the next relay launch."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
None => CAMERA_PREVIEW_IDLE.to_string(),
|
None => CAMERA_PREVIEW_IDLE.to_string(),
|
||||||
@ -728,19 +770,23 @@ fn run_microphone_monitor_feed(
|
|||||||
fn run_camera_preview_feed(
|
fn run_camera_preview_feed(
|
||||||
selected: String,
|
selected: String,
|
||||||
device: String,
|
device: String,
|
||||||
|
mode: Option<CameraMode>,
|
||||||
token: u64,
|
token: u64,
|
||||||
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
||||||
status_text: Arc<Mutex<String>>,
|
status_text: Arc<Mutex<String>>,
|
||||||
generation: Arc<AtomicU64>,
|
generation: Arc<AtomicU64>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (pipeline, appsink) = build_camera_preview_pipeline(&device)?;
|
let (pipeline, appsink) = build_camera_preview_pipeline(&device, mode)?;
|
||||||
pipeline
|
pipeline
|
||||||
.set_state(gst::State::Playing)
|
.set_state(gst::State::Playing)
|
||||||
.context("starting in-launcher camera preview pipeline")?;
|
.context("starting in-launcher camera preview pipeline")?;
|
||||||
|
|
||||||
if let Ok(mut status) = status_text.lock() {
|
if let Ok(mut status) = status_text.lock() {
|
||||||
*status = format!("Local preview live for {selected}.");
|
let quality = mode
|
||||||
|
.map(CameraMode::short_label)
|
||||||
|
.unwrap_or_else(|| "default quality".to_string());
|
||||||
|
*status = format!("Local preview live for {selected} at {quality}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
|
while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
|
||||||
@ -815,8 +861,11 @@ fn run_microphone_level_probe(
|
|||||||
running.store(false, Ordering::Release);
|
running.store(false, Ordering::Release);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_camera_preview_pipeline(device: &str) -> Result<(gst::Pipeline, gst_app::AppSink)> {
|
fn build_camera_preview_pipeline(
|
||||||
let desc = camera_preview_pipeline_desc(device);
|
device: &str,
|
||||||
|
mode: Option<CameraMode>,
|
||||||
|
) -> Result<(gst::Pipeline, gst_app::AppSink)> {
|
||||||
|
let desc = camera_preview_pipeline_desc(device, mode);
|
||||||
let pipeline = gst::parse::launch(&desc)?
|
let pipeline = gst::parse::launch(&desc)?
|
||||||
.downcast::<gst::Pipeline>()
|
.downcast::<gst::Pipeline>()
|
||||||
.expect("camera preview pipeline");
|
.expect("camera preview pipeline");
|
||||||
@ -858,11 +907,19 @@ fn build_microphone_monitor_pipeline(
|
|||||||
Ok((pipeline, appsink))
|
Ok((pipeline, appsink))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn camera_preview_pipeline_desc(device: &str) -> String {
|
fn camera_preview_pipeline_desc(device: &str, mode: Option<CameraMode>) -> String {
|
||||||
let device = gst_quote(device);
|
let device = gst_quote(device);
|
||||||
|
let source_caps = mode
|
||||||
|
.map(|mode| {
|
||||||
|
format!(
|
||||||
|
"capsfilter caps=\"video/x-raw,width=(int){},height=(int){},framerate=(fraction){}/1;image/jpeg,width=(int){},height=(int){},framerate=(fraction){}/1\" ! decodebin ! ",
|
||||||
|
mode.width, mode.height, mode.fps, mode.width, mode.height, mode.fps
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
"v4l2src device=\"{device}\" do-timestamp=true ! \
|
"v4l2src device=\"{device}\" do-timestamp=true ! \
|
||||||
videoconvert ! videoscale ! videorate ! \
|
{source_caps}videoconvert ! videoscale ! videorate ! \
|
||||||
video/x-raw,format=RGBA,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},framerate=30/1,pixel-aspect-ratio=1/1 ! \
|
video/x-raw,format=RGBA,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},framerate=30/1,pixel-aspect-ratio=1/1 ! \
|
||||||
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true"
|
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true"
|
||||||
)
|
)
|
||||||
@ -1053,6 +1110,7 @@ mod tests {
|
|||||||
microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio,
|
microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio,
|
||||||
read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device,
|
read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device,
|
||||||
};
|
};
|
||||||
|
use crate::launcher::devices::CameraMode;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1077,12 +1135,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn camera_preview_pipeline_scales_after_source_instead_of_pinning_raw_source_caps() {
|
fn camera_preview_pipeline_scales_after_source_instead_of_pinning_raw_source_caps() {
|
||||||
let desc = camera_preview_pipeline_desc("/dev/video0");
|
let desc = camera_preview_pipeline_desc("/dev/video0", None);
|
||||||
assert!(desc.contains("v4l2src device=\"/dev/video0\""));
|
assert!(desc.contains("v4l2src device=\"/dev/video0\""));
|
||||||
assert!(desc.contains("videoconvert ! videoscale ! videorate !"));
|
assert!(desc.contains("videoconvert ! videoscale ! videorate !"));
|
||||||
assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,"));
|
assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_preview_pipeline_requests_selected_webcam_quality_before_scaling() {
|
||||||
|
let desc =
|
||||||
|
camera_preview_pipeline_desc("/dev/video0", Some(CameraMode::new(1920, 1080, 30)));
|
||||||
|
assert!(
|
||||||
|
desc.contains("video/x-raw,width=(int)1920,height=(int)1080,framerate=(fraction)30/1")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
desc.contains("image/jpeg,width=(int)1920,height=(int)1080,framerate=(fraction)30/1")
|
||||||
|
);
|
||||||
|
assert!(desc.contains("decodebin ! videoconvert ! videoscale"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() {
|
fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() {
|
||||||
let desc = microphone_monitor_pipeline_desc(
|
let desc = microphone_monitor_pipeline_desc(
|
||||||
|
|||||||
@ -1,17 +1,61 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct DeviceCatalog {
|
pub struct DeviceCatalog {
|
||||||
pub cameras: Vec<String>,
|
pub cameras: Vec<String>,
|
||||||
|
pub camera_modes: BTreeMap<String, Vec<CameraMode>>,
|
||||||
pub microphones: Vec<String>,
|
pub microphones: Vec<String>,
|
||||||
pub speakers: Vec<String>,
|
pub speakers: Vec<String>,
|
||||||
pub keyboards: Vec<String>,
|
pub keyboards: Vec<String>,
|
||||||
pub mice: Vec<String>,
|
pub mice: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
pub struct CameraMode {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fps: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CameraMode {
|
||||||
|
pub const fn new(width: u32, height: u32, fps: u32) -> Self {
|
||||||
|
Self { width, height, fps }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(self) -> String {
|
||||||
|
format!("{}x{}@{}", self.width, self.height, self.fps)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_id(raw: &str) -> Option<Self> {
|
||||||
|
let (size, fps) = raw.split_once('@')?;
|
||||||
|
let (width, height) = size.split_once('x')?;
|
||||||
|
Some(Self {
|
||||||
|
width: width.parse().ok()?,
|
||||||
|
height: height.parse().ok()?,
|
||||||
|
fps: fps.parse().ok()?,
|
||||||
|
})
|
||||||
|
.filter(|mode| mode.width > 0 && mode.height > 0 && mode.fps > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn short_label(self) -> String {
|
||||||
|
format!("{}p@{}", self.height, self.fps)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn h264_bitrate_kbit(self) -> u32 {
|
||||||
|
if self.width >= 1920 || self.height >= 1080 {
|
||||||
|
12_000
|
||||||
|
} else if self.width >= 1280 || self.height >= 720 {
|
||||||
|
6_000
|
||||||
|
} else {
|
||||||
|
3_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DeviceCatalog {
|
impl DeviceCatalog {
|
||||||
pub fn discover() -> Self {
|
pub fn discover() -> Self {
|
||||||
Self::discover_with_camera_override(std::env::var("LESAVKA_LAUNCHER_CAMERA_DIR").ok())
|
Self::discover_with_camera_override(std::env::var("LESAVKA_LAUNCHER_CAMERA_DIR").ok())
|
||||||
@ -27,12 +71,14 @@ impl DeviceCatalog {
|
|||||||
|
|
||||||
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
|
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
|
||||||
let cameras = discover_camera_devices(override_dir);
|
let cameras = discover_camera_devices(override_dir);
|
||||||
|
let camera_modes = discover_camera_modes(&cameras);
|
||||||
let microphones = discover_microphone_devices();
|
let microphones = discover_microphone_devices();
|
||||||
let speakers = discover_speaker_devices();
|
let speakers = discover_speaker_devices();
|
||||||
let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
|
let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
|
||||||
let mice = discover_input_devices(InputDeviceKind::Mouse);
|
let mice = discover_input_devices(InputDeviceKind::Mouse);
|
||||||
Self {
|
Self {
|
||||||
cameras,
|
cameras,
|
||||||
|
camera_modes,
|
||||||
microphones,
|
microphones,
|
||||||
speakers,
|
speakers,
|
||||||
keyboards,
|
keyboards,
|
||||||
@ -118,6 +164,87 @@ fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
|
|||||||
dedupe_camera_devices(set)
|
dedupe_camera_devices(set)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn discover_camera_modes(cameras: &[String]) -> BTreeMap<String, Vec<CameraMode>> {
|
||||||
|
cameras
|
||||||
|
.iter()
|
||||||
|
.filter_map(|camera| {
|
||||||
|
let modes = discover_camera_modes_for(camera);
|
||||||
|
(!modes.is_empty()).then(|| (camera.clone(), modes))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_camera_modes_for(camera: &str) -> Vec<CameraMode> {
|
||||||
|
let device = if camera.starts_with("/dev/") {
|
||||||
|
camera.to_string()
|
||||||
|
} else {
|
||||||
|
format!("/dev/v4l/by-id/{camera}")
|
||||||
|
};
|
||||||
|
let output = std::process::Command::new("v4l2-ctl")
|
||||||
|
.args(["--list-formats-ext", "-d", &device])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let Ok(output) = output else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !output.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
parse_supported_camera_modes(&String::from_utf8_lossy(&output.stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lesavka_supported_camera_modes() -> Vec<CameraMode> {
|
||||||
|
vec![
|
||||||
|
CameraMode::new(1920, 1080, 30),
|
||||||
|
CameraMode::new(1280, 720, 30),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_supported_camera_modes(stdout: &str) -> Vec<CameraMode> {
|
||||||
|
let mut discovered = BTreeSet::new();
|
||||||
|
let mut current_size = None::<(u32, u32)>;
|
||||||
|
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(size) = parse_v4l2_size_line(trimmed) {
|
||||||
|
current_size = Some(size);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("Interval:")
|
||||||
|
&& let Some((width, height)) = current_size
|
||||||
|
&& let Some(fps) = parse_v4l2_interval_fps(trimmed)
|
||||||
|
{
|
||||||
|
discovered.insert(CameraMode::new(width, height, fps));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lesavka_supported_camera_modes()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|supported| {
|
||||||
|
discovered.iter().any(|actual| {
|
||||||
|
actual.width == supported.width
|
||||||
|
&& actual.height == supported.height
|
||||||
|
&& actual.fps >= supported.fps
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_v4l2_size_line(line: &str) -> Option<(u32, u32)> {
|
||||||
|
let (_, rest) = line.split_once("Size:")?;
|
||||||
|
let token = rest.split_whitespace().find(|part| part.contains('x'))?;
|
||||||
|
let (width, height) = token.split_once('x')?;
|
||||||
|
Some((width.parse().ok()?, height.parse().ok()?))
|
||||||
|
.filter(|(width, height)| *width > 0 && *height > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_v4l2_interval_fps(line: &str) -> Option<u32> {
|
||||||
|
let (_, rest) = line.split_once('(')?;
|
||||||
|
let (fps, _) = rest.split_once(" fps")?;
|
||||||
|
let rounded = fps.parse::<f32>().ok()?.round() as u32;
|
||||||
|
(rounded > 0).then_some(rounded)
|
||||||
|
}
|
||||||
|
|
||||||
fn discover_pactl_devices(kind: &str) -> Vec<String> {
|
fn discover_pactl_devices(kind: &str) -> Vec<String> {
|
||||||
let output = std::process::Command::new("pactl")
|
let output = std::process::Command::new("pactl")
|
||||||
.args(["list", "short", kind])
|
.args(["list", "short", kind])
|
||||||
@ -331,6 +458,43 @@ mod tests {
|
|||||||
let _ = discover_camera_devices(None);
|
let _ = discover_camera_devices(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_mode_parser_keeps_only_supported_lesavka_qualities() {
|
||||||
|
let stdout = r#"
|
||||||
|
ioctl: VIDIOC_ENUM_FMT
|
||||||
|
Type: Video Capture
|
||||||
|
|
||||||
|
[0]: 'MJPG' (Motion-JPEG, compressed)
|
||||||
|
Size: Discrete 1920x1080
|
||||||
|
Interval: Discrete 0.033s (30.000 fps)
|
||||||
|
Interval: Discrete 0.067s (15.000 fps)
|
||||||
|
Size: Discrete 1280x720
|
||||||
|
Interval: Discrete 0.017s (60.000 fps)
|
||||||
|
Size: Discrete 640x480
|
||||||
|
Interval: Discrete 0.033s (30.000 fps)
|
||||||
|
[1]: 'YUYV' (YUYV 4:2:2)
|
||||||
|
Size: Discrete 1920x1080
|
||||||
|
Interval: Discrete 0.200s (5.000 fps)
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_supported_camera_modes(stdout),
|
||||||
|
vec![
|
||||||
|
CameraMode::new(1920, 1080, 30),
|
||||||
|
CameraMode::new(1280, 720, 30)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_mode_ids_are_short_and_round_trippable() {
|
||||||
|
let mode = CameraMode::new(1920, 1080, 30);
|
||||||
|
assert_eq!(mode.id(), "1920x1080@30");
|
||||||
|
assert_eq!(mode.short_label(), "1080p@30");
|
||||||
|
assert_eq!(CameraMode::from_id("1920x1080@30"), Some(mode));
|
||||||
|
assert_eq!(CameraMode::from_id("not-a-mode"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discover_uses_override_and_tolerates_missing_pactl() {
|
fn discover_uses_override_and_tolerates_missing_pactl() {
|
||||||
let tmp = mk_temp_dir("discover-override");
|
let tmp = mk_temp_dir("discover-override");
|
||||||
|
|||||||
@ -157,6 +157,15 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
|
|||||||
&& let Some(camera) = state.devices.camera.as_ref()
|
&& let Some(camera) = state.devices.camera.as_ref()
|
||||||
{
|
{
|
||||||
envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone());
|
envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone());
|
||||||
|
if let Some(mode) = state.camera_quality {
|
||||||
|
envs.insert("LESAVKA_CAM_WIDTH".to_string(), mode.width.to_string());
|
||||||
|
envs.insert("LESAVKA_CAM_HEIGHT".to_string(), mode.height.to_string());
|
||||||
|
envs.insert("LESAVKA_CAM_FPS".to_string(), mode.fps.to_string());
|
||||||
|
envs.insert(
|
||||||
|
"LESAVKA_CAM_H264_KBIT".to_string(),
|
||||||
|
mode.h264_bitrate_kbit().to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string());
|
envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string());
|
||||||
}
|
}
|
||||||
@ -256,6 +265,7 @@ mod tests {
|
|||||||
state.set_routing(InputRouting::Local);
|
state.set_routing(InputRouting::Local);
|
||||||
state.set_view_mode(ViewMode::Unified);
|
state.set_view_mode(ViewMode::Unified);
|
||||||
state.select_camera(Some("/dev/video0".to_string()));
|
state.select_camera(Some("/dev/video0".to_string()));
|
||||||
|
state.select_camera_quality(Some(devices::CameraMode::new(1920, 1080, 30)));
|
||||||
state.select_microphone(Some("alsa_input.test".to_string()));
|
state.select_microphone(Some("alsa_input.test".to_string()));
|
||||||
state.select_speaker(Some("alsa_output.test".to_string()));
|
state.select_speaker(Some("alsa_output.test".to_string()));
|
||||||
state.set_camera_channel_enabled(true);
|
state.set_camera_channel_enabled(true);
|
||||||
@ -275,6 +285,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string()));
|
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string()));
|
||||||
assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string()));
|
assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string()));
|
||||||
|
assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string()));
|
||||||
|
assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string()));
|
||||||
|
assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_CAM_H264_KBIT"),
|
||||||
|
Some(&"12000".to_string())
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
||||||
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::devices::DeviceCatalog;
|
use super::devices::{CameraMode, DeviceCatalog};
|
||||||
use lesavka_common::eye_source::{
|
use lesavka_common::eye_source::{
|
||||||
EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes,
|
EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes,
|
||||||
};
|
};
|
||||||
@ -336,6 +336,7 @@ pub struct LauncherState {
|
|||||||
pub capture_bitrates_kbit: [u32; 2],
|
pub capture_bitrates_kbit: [u32; 2],
|
||||||
pub breakout_sizes: [BreakoutSizePreset; 2],
|
pub breakout_sizes: [BreakoutSizePreset; 2],
|
||||||
pub devices: DeviceSelection,
|
pub devices: DeviceSelection,
|
||||||
|
pub camera_quality: Option<CameraMode>,
|
||||||
pub channels: ChannelSelection,
|
pub channels: ChannelSelection,
|
||||||
pub audio_gain_percent: u32,
|
pub audio_gain_percent: u32,
|
||||||
pub mic_gain_percent: u32,
|
pub mic_gain_percent: u32,
|
||||||
@ -364,6 +365,7 @@ impl Default for LauncherState {
|
|||||||
capture_bitrates_kbit: [18_000, 18_000],
|
capture_bitrates_kbit: [18_000, 18_000],
|
||||||
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
||||||
devices: DeviceSelection::default(),
|
devices: DeviceSelection::default(),
|
||||||
|
camera_quality: None,
|
||||||
channels: ChannelSelection::default(),
|
channels: ChannelSelection::default(),
|
||||||
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
|
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
|
||||||
mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT,
|
mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT,
|
||||||
@ -658,6 +660,30 @@ impl LauncherState {
|
|||||||
self.devices.camera = normalize_selection(camera);
|
self.devices.camera = normalize_selection(camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn select_camera_quality(&mut self, mode: Option<CameraMode>) {
|
||||||
|
self.camera_quality = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec<CameraMode> {
|
||||||
|
self.devices
|
||||||
|
.camera
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|camera| catalog.camera_modes.get(camera))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_camera_quality(&self, catalog: &DeviceCatalog) -> Option<CameraMode> {
|
||||||
|
let options = self.camera_quality_options(catalog);
|
||||||
|
self.camera_quality
|
||||||
|
.filter(|selected| options.contains(selected))
|
||||||
|
.or_else(|| options.first().copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_camera_quality(&mut self, catalog: &DeviceCatalog) {
|
||||||
|
self.camera_quality = self.selected_camera_quality(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn select_microphone(&mut self, microphone: Option<String>) {
|
pub fn select_microphone(&mut self, microphone: Option<String>) {
|
||||||
self.devices.microphone = normalize_selection(microphone);
|
self.devices.microphone = normalize_selection(microphone);
|
||||||
}
|
}
|
||||||
@ -720,6 +746,7 @@ impl LauncherState {
|
|||||||
|
|
||||||
pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) {
|
pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) {
|
||||||
keep_or_select_first(&mut self.devices.camera, &catalog.cameras);
|
keep_or_select_first(&mut self.devices.camera, &catalog.cameras);
|
||||||
|
self.normalize_camera_quality(catalog);
|
||||||
keep_or_select_first(&mut self.devices.microphone, &catalog.microphones);
|
keep_or_select_first(&mut self.devices.microphone, &catalog.microphones);
|
||||||
keep_or_select_first(&mut self.devices.speaker, &catalog.speakers);
|
keep_or_select_first(&mut self.devices.speaker, &catalog.speakers);
|
||||||
}
|
}
|
||||||
@ -778,7 +805,7 @@ impl LauncherState {
|
|||||||
|
|
||||||
pub fn status_line(&self) -> String {
|
pub fn status_line(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
|
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
|
||||||
self.server_available,
|
self.server_available,
|
||||||
match self.routing {
|
match self.routing {
|
||||||
InputRouting::Local => "local",
|
InputRouting::Local => "local",
|
||||||
@ -801,6 +828,9 @@ impl LauncherState {
|
|||||||
self.feed_source_preset(0).as_id(),
|
self.feed_source_preset(0).as_id(),
|
||||||
self.feed_source_preset(1).as_id(),
|
self.feed_source_preset(1).as_id(),
|
||||||
media_status_label(self.channels.camera, self.devices.camera.as_deref()),
|
media_status_label(self.channels.camera, self.devices.camera.as_deref()),
|
||||||
|
self.camera_quality
|
||||||
|
.map(CameraMode::short_label)
|
||||||
|
.unwrap_or_else(|| "default".to_string()),
|
||||||
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
|
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
|
||||||
media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
|
media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
|
||||||
self.channels.camera,
|
self.channels.camera,
|
||||||
@ -1179,6 +1209,12 @@ mod tests {
|
|||||||
|
|
||||||
let catalog = DeviceCatalog {
|
let catalog = DeviceCatalog {
|
||||||
cameras: vec!["/dev/video0".to_string()],
|
cameras: vec!["/dev/video0".to_string()],
|
||||||
|
camera_modes: [(
|
||||||
|
"/dev/video0".to_string(),
|
||||||
|
vec![CameraMode::new(1920, 1080, 30)],
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
microphones: vec!["alsa_input.usb".to_string()],
|
microphones: vec!["alsa_input.usb".to_string()],
|
||||||
speakers: vec!["alsa_output.usb".to_string()],
|
speakers: vec!["alsa_output.usb".to_string()],
|
||||||
keyboards: vec!["/dev/input/event10".to_string()],
|
keyboards: vec!["/dev/input/event10".to_string()],
|
||||||
@ -1197,10 +1233,56 @@ mod tests {
|
|||||||
let mut fresh = LauncherState::new();
|
let mut fresh = LauncherState::new();
|
||||||
fresh.apply_catalog_defaults(&catalog);
|
fresh.apply_catalog_defaults(&catalog);
|
||||||
assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0"));
|
assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0"));
|
||||||
|
assert_eq!(fresh.camera_quality, Some(CameraMode::new(1920, 1080, 30)));
|
||||||
assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb"));
|
assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb"));
|
||||||
assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb"));
|
assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_quality_tracks_selected_camera_supported_modes() {
|
||||||
|
let catalog = DeviceCatalog {
|
||||||
|
cameras: vec!["cam-a".to_string(), "cam-b".to_string()],
|
||||||
|
camera_modes: [
|
||||||
|
(
|
||||||
|
"cam-a".to_string(),
|
||||||
|
vec![
|
||||||
|
CameraMode::new(1920, 1080, 30),
|
||||||
|
CameraMode::new(1280, 720, 30),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
..DeviceCatalog::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.apply_catalog_defaults(&catalog);
|
||||||
|
assert_eq!(state.devices.camera.as_deref(), Some("cam-a"));
|
||||||
|
assert_eq!(
|
||||||
|
state.camera_quality_options(&catalog),
|
||||||
|
vec![
|
||||||
|
CameraMode::new(1920, 1080, 30),
|
||||||
|
CameraMode::new(1280, 720, 30)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
state.select_camera_quality(Some(CameraMode::new(1280, 720, 30)));
|
||||||
|
assert_eq!(
|
||||||
|
state.selected_camera_quality(&catalog),
|
||||||
|
Some(CameraMode::new(1280, 720, 30))
|
||||||
|
);
|
||||||
|
|
||||||
|
state.select_camera(Some("cam-b".to_string()));
|
||||||
|
state.normalize_camera_quality(&catalog);
|
||||||
|
assert_eq!(state.camera_quality, Some(CameraMode::new(1280, 720, 30)));
|
||||||
|
|
||||||
|
state.select_camera(None);
|
||||||
|
state.normalize_camera_quality(&catalog);
|
||||||
|
assert_eq!(state.camera_quality, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() {
|
fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() {
|
||||||
let mut state = LauncherState::new();
|
let mut state = LauncherState::new();
|
||||||
@ -1244,6 +1326,7 @@ mod tests {
|
|||||||
state.set_routing(InputRouting::Local);
|
state.set_routing(InputRouting::Local);
|
||||||
state.set_view_mode(ViewMode::Unified);
|
state.set_view_mode(ViewMode::Unified);
|
||||||
state.select_camera(Some("/dev/video0".to_string()));
|
state.select_camera(Some("/dev/video0".to_string()));
|
||||||
|
state.select_camera_quality(Some(CameraMode::new(1920, 1080, 30)));
|
||||||
state.select_microphone(Some("alsa_input.usb".to_string()));
|
state.select_microphone(Some("alsa_input.usb".to_string()));
|
||||||
state.select_speaker(Some("alsa_output.usb".to_string()));
|
state.select_speaker(Some("alsa_output.usb".to_string()));
|
||||||
state.set_camera_channel_enabled(true);
|
state.set_camera_channel_enabled(true);
|
||||||
@ -1263,6 +1346,7 @@ mod tests {
|
|||||||
assert!(status.contains("d1=preview"));
|
assert!(status.contains("d1=preview"));
|
||||||
assert!(status.contains("d2=preview"));
|
assert!(status.contains("d2=preview"));
|
||||||
assert!(status.contains("camera=/dev/video0"));
|
assert!(status.contains("camera=/dev/video0"));
|
||||||
|
assert!(status.contains("camera_quality=1080p@30"));
|
||||||
assert!(status.contains("mic=alsa_input.usb"));
|
assert!(status.contains("mic=alsa_input.usb"));
|
||||||
assert!(status.contains("speaker=alsa_output.usb"));
|
assert!(status.contains("speaker=alsa_output.usb"));
|
||||||
assert!(status.contains("audio_gain=200%"));
|
assert!(status.contains("audio_gain=200%"));
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use anyhow::Result;
|
|||||||
use {
|
use {
|
||||||
super::clipboard::send_clipboard_text_to_remote,
|
super::clipboard::send_clipboard_text_to_remote,
|
||||||
super::device_test::{DeviceTestController, DeviceTestKind},
|
super::device_test::{DeviceTestController, DeviceTestKind},
|
||||||
super::devices::DeviceCatalog,
|
super::devices::{CameraMode, DeviceCatalog},
|
||||||
super::diagnostics::{PerformanceSample, quality_probe_command},
|
super::diagnostics::{PerformanceSample, quality_probe_command},
|
||||||
super::launcher_clipboard_control_path,
|
super::launcher_clipboard_control_path,
|
||||||
super::launcher_focus_signal_path,
|
super::launcher_focus_signal_path,
|
||||||
@ -14,7 +14,10 @@ use {
|
|||||||
FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
||||||
MAX_MIC_GAIN_PERCENT,
|
MAX_MIC_GAIN_PERCENT,
|
||||||
},
|
},
|
||||||
super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo},
|
super::ui_components::{
|
||||||
|
build_launcher_view, sync_camera_quality_combo, sync_input_device_combo,
|
||||||
|
sync_stage_device_combo,
|
||||||
|
},
|
||||||
super::ui_runtime::{
|
super::ui_runtime::{
|
||||||
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
||||||
audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log,
|
audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log,
|
||||||
@ -153,6 +156,25 @@ fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option<
|
|||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn selected_camera_quality(combo: >k::ComboBoxText) -> Option<CameraMode> {
|
||||||
|
combo.active_id().as_deref().and_then(CameraMode::from_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn sync_camera_quality_selection(
|
||||||
|
combo: >k::ComboBoxText,
|
||||||
|
state: &mut LauncherState,
|
||||||
|
catalog: &DeviceCatalog,
|
||||||
|
) {
|
||||||
|
state.normalize_camera_quality(catalog);
|
||||||
|
sync_camera_quality_combo(
|
||||||
|
combo,
|
||||||
|
&state.camera_quality_options(catalog),
|
||||||
|
state.camera_quality,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
|
fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
|
||||||
if samples.len() < 2 {
|
if samples.len() < 2 {
|
||||||
@ -705,9 +727,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let app = gtk::Application::builder()
|
let app = gtk::Application::builder()
|
||||||
.application_id("dev.lesavka.launcher")
|
.application_id("dev.lesavka.launcher")
|
||||||
.build();
|
.build();
|
||||||
let catalog = Rc::new(DeviceCatalog::discover());
|
let catalog = Rc::new(RefCell::new(DeviceCatalog::discover()));
|
||||||
let state = Rc::new(RefCell::new(LauncherState::new()));
|
let state = Rc::new(RefCell::new(LauncherState::new()));
|
||||||
state.borrow_mut().apply_catalog_defaults(&catalog);
|
state.borrow_mut().apply_catalog_defaults(&catalog.borrow());
|
||||||
let child_proc = Rc::new(RefCell::new(None::<RelayChild>));
|
let child_proc = Rc::new(RefCell::new(None::<RelayChild>));
|
||||||
let tests = Rc::new(RefCell::new(DeviceTestController::new()));
|
let tests = Rc::new(RefCell::new(DeviceTestController::new()));
|
||||||
let server_addr = Rc::new(server_addr);
|
let server_addr = Rc::new(server_addr);
|
||||||
@ -760,12 +782,14 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
state.set_breakout_display_size(display_width, display_height);
|
state.set_breakout_display_size(display_width, display_height);
|
||||||
state.set_breakout_limit_size(physical_width, physical_height);
|
state.set_breakout_limit_size(physical_width, physical_height);
|
||||||
}
|
}
|
||||||
let view = build_launcher_view(app, server_addr.as_ref(), &catalog, &state.borrow());
|
let view =
|
||||||
|
build_launcher_view(app, server_addr.as_ref(), &catalog.borrow(), &state.borrow());
|
||||||
let window = view.window.clone();
|
let window = view.window.clone();
|
||||||
let (launcher_width, launcher_height) = launcher_default_size(display_width, display_height);
|
let (launcher_width, launcher_height) = launcher_default_size(display_width, display_height);
|
||||||
window.set_default_size(launcher_width, launcher_height);
|
window.set_default_size(launcher_width, launcher_height);
|
||||||
let server_entry = view.server_entry.clone();
|
let server_entry = view.server_entry.clone();
|
||||||
let camera_combo = view.camera_combo.clone();
|
let camera_combo = view.camera_combo.clone();
|
||||||
|
let camera_quality_combo = view.camera_quality_combo.clone();
|
||||||
let microphone_combo = view.microphone_combo.clone();
|
let microphone_combo = view.microphone_combo.clone();
|
||||||
let speaker_combo = view.speaker_combo.clone();
|
let speaker_combo = view.speaker_combo.clone();
|
||||||
let keyboard_combo = view.keyboard_combo.clone();
|
let keyboard_combo = view.keyboard_combo.clone();
|
||||||
@ -848,6 +872,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
.status_label
|
.status_label
|
||||||
.set_text(&format!("Camera staging setup failed: {err}"));
|
.set_text(&format!("Camera staging setup failed: {err}"));
|
||||||
}
|
}
|
||||||
|
if let Err(err) = tests.set_camera_quality(state.borrow().camera_quality) {
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("Camera quality staging setup failed: {err}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||||
@ -878,24 +907,68 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let state = Rc::clone(&state);
|
let state = Rc::clone(&state);
|
||||||
|
let catalog = Rc::clone(&catalog);
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
let child_proc = Rc::clone(&child_proc);
|
let child_proc = Rc::clone(&child_proc);
|
||||||
let tests = Rc::clone(&tests);
|
let tests = Rc::clone(&tests);
|
||||||
let camera_combo = camera_combo.clone();
|
let camera_combo = camera_combo.clone();
|
||||||
|
let camera_quality_combo = camera_quality_combo.clone();
|
||||||
let camera_combo_read = camera_combo.clone();
|
let camera_combo_read = camera_combo.clone();
|
||||||
camera_combo.connect_changed(move |_| {
|
camera_combo.connect_changed(move |_| {
|
||||||
let selected = selected_combo_value(&camera_combo_read);
|
let selected = selected_combo_value(&camera_combo_read);
|
||||||
let preview_was_running =
|
let preview_was_running =
|
||||||
tests.borrow_mut().is_running(DeviceTestKind::Camera);
|
tests.borrow_mut().is_running(DeviceTestKind::Camera);
|
||||||
state.borrow_mut().select_camera(selected.clone());
|
{
|
||||||
|
let catalog = catalog.borrow();
|
||||||
|
let mut state = state.borrow_mut();
|
||||||
|
state.select_camera(selected.clone());
|
||||||
|
sync_camera_quality_selection(&camera_quality_combo, &mut state, &catalog);
|
||||||
|
}
|
||||||
|
let quality = state.borrow().camera_quality;
|
||||||
if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) {
|
if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) {
|
||||||
widgets
|
widgets
|
||||||
.status_label
|
.status_label
|
||||||
.set_text(&format!("Camera preview update failed: {err}"));
|
.set_text(&format!("Camera preview update failed: {err}"));
|
||||||
|
} else if let Err(err) = tests.borrow_mut().set_camera_quality(quality) {
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("Camera quality update failed: {err}"));
|
||||||
|
} else if preview_was_running {
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"Local camera preview switched to {}{}.",
|
||||||
|
selected.as_deref().unwrap_or("no camera"),
|
||||||
|
quality
|
||||||
|
.map(|mode| format!(" at {}", mode.short_label()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||||
|
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let state = Rc::clone(&state);
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let child_proc = Rc::clone(&child_proc);
|
||||||
|
let tests = Rc::clone(&tests);
|
||||||
|
let camera_quality_combo = camera_quality_combo.clone();
|
||||||
|
let camera_quality_combo_read = camera_quality_combo.clone();
|
||||||
|
camera_quality_combo.connect_changed(move |_| {
|
||||||
|
let selected = selected_camera_quality(&camera_quality_combo_read);
|
||||||
|
let preview_was_running =
|
||||||
|
tests.borrow_mut().is_running(DeviceTestKind::Camera);
|
||||||
|
state.borrow_mut().select_camera_quality(selected);
|
||||||
|
if let Err(err) = tests.borrow_mut().set_camera_quality(selected) {
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("Camera quality update failed: {err}"));
|
||||||
} else if preview_was_running {
|
} else if preview_was_running {
|
||||||
widgets.status_label.set_text(&format!(
|
widgets.status_label.set_text(&format!(
|
||||||
"Local camera preview switched to {}.",
|
"Local camera preview switched to {}.",
|
||||||
selected.as_deref().unwrap_or("no camera")
|
selected
|
||||||
|
.map(CameraMode::short_label)
|
||||||
|
.unwrap_or_else(|| "default quality".to_string())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||||
@ -1259,17 +1332,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let state = Rc::clone(&state);
|
let state = Rc::clone(&state);
|
||||||
|
let catalog_state = Rc::clone(&catalog);
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
let widgets_handle = widgets.clone();
|
let widgets_handle = widgets.clone();
|
||||||
let child_proc = Rc::clone(&child_proc);
|
let child_proc = Rc::clone(&child_proc);
|
||||||
let tests = Rc::clone(&tests);
|
let tests = Rc::clone(&tests);
|
||||||
let camera_combo = camera_combo.clone();
|
let camera_combo = camera_combo.clone();
|
||||||
|
let camera_quality_combo = camera_quality_combo.clone();
|
||||||
let microphone_combo = microphone_combo.clone();
|
let microphone_combo = microphone_combo.clone();
|
||||||
let speaker_combo = speaker_combo.clone();
|
let speaker_combo = speaker_combo.clone();
|
||||||
let keyboard_combo = keyboard_combo.clone();
|
let keyboard_combo = keyboard_combo.clone();
|
||||||
let mouse_combo = mouse_combo.clone();
|
let mouse_combo = mouse_combo.clone();
|
||||||
widgets.device_refresh_button.connect_clicked(move |_| {
|
widgets.device_refresh_button.connect_clicked(move |_| {
|
||||||
let catalog = DeviceCatalog::discover();
|
let fresh_catalog = DeviceCatalog::discover();
|
||||||
let (
|
let (
|
||||||
selected_camera,
|
selected_camera,
|
||||||
selected_microphone,
|
selected_microphone,
|
||||||
@ -1281,56 +1356,65 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
(
|
(
|
||||||
retained_stage_selection(
|
retained_stage_selection(
|
||||||
state.devices.camera.as_deref(),
|
state.devices.camera.as_deref(),
|
||||||
&catalog.cameras,
|
&fresh_catalog.cameras,
|
||||||
),
|
),
|
||||||
retained_stage_selection(
|
retained_stage_selection(
|
||||||
state.devices.microphone.as_deref(),
|
state.devices.microphone.as_deref(),
|
||||||
&catalog.microphones,
|
&fresh_catalog.microphones,
|
||||||
),
|
),
|
||||||
retained_stage_selection(
|
retained_stage_selection(
|
||||||
state.devices.speaker.as_deref(),
|
state.devices.speaker.as_deref(),
|
||||||
&catalog.speakers,
|
&fresh_catalog.speakers,
|
||||||
),
|
),
|
||||||
retained_input_selection(
|
retained_input_selection(
|
||||||
state.devices.keyboard.as_deref(),
|
state.devices.keyboard.as_deref(),
|
||||||
&catalog.keyboards,
|
&fresh_catalog.keyboards,
|
||||||
|
),
|
||||||
|
retained_input_selection(
|
||||||
|
state.devices.mouse.as_deref(),
|
||||||
|
&fresh_catalog.mice,
|
||||||
),
|
),
|
||||||
retained_input_selection(state.devices.mouse.as_deref(), &catalog.mice),
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let mut state = state.borrow_mut();
|
let mut state = state.borrow_mut();
|
||||||
state.select_camera(selected_camera);
|
state.select_camera(selected_camera);
|
||||||
|
sync_camera_quality_selection(
|
||||||
|
&camera_quality_combo,
|
||||||
|
&mut state,
|
||||||
|
&fresh_catalog,
|
||||||
|
);
|
||||||
state.select_microphone(selected_microphone);
|
state.select_microphone(selected_microphone);
|
||||||
state.select_speaker(selected_speaker);
|
state.select_speaker(selected_speaker);
|
||||||
state.select_keyboard(selected_keyboard);
|
state.select_keyboard(selected_keyboard);
|
||||||
state.select_mouse(selected_mouse);
|
state.select_mouse(selected_mouse);
|
||||||
}
|
}
|
||||||
|
*catalog_state.borrow_mut() = fresh_catalog.clone();
|
||||||
let state_snapshot = state.borrow().clone();
|
let state_snapshot = state.borrow().clone();
|
||||||
sync_stage_device_combo(
|
sync_stage_device_combo(
|
||||||
&camera_combo,
|
&camera_combo,
|
||||||
&catalog.cameras,
|
&fresh_catalog.cameras,
|
||||||
state_snapshot.devices.camera.as_deref(),
|
state_snapshot.devices.camera.as_deref(),
|
||||||
);
|
);
|
||||||
sync_stage_device_combo(
|
sync_stage_device_combo(
|
||||||
µphone_combo,
|
µphone_combo,
|
||||||
&catalog.microphones,
|
&fresh_catalog.microphones,
|
||||||
state_snapshot.devices.microphone.as_deref(),
|
state_snapshot.devices.microphone.as_deref(),
|
||||||
);
|
);
|
||||||
sync_stage_device_combo(
|
sync_stage_device_combo(
|
||||||
&speaker_combo,
|
&speaker_combo,
|
||||||
&catalog.speakers,
|
&fresh_catalog.speakers,
|
||||||
state_snapshot.devices.speaker.as_deref(),
|
state_snapshot.devices.speaker.as_deref(),
|
||||||
);
|
);
|
||||||
sync_input_device_combo(
|
sync_input_device_combo(
|
||||||
&keyboard_combo,
|
&keyboard_combo,
|
||||||
&catalog.keyboards,
|
&fresh_catalog.keyboards,
|
||||||
state_snapshot.devices.keyboard.as_deref(),
|
state_snapshot.devices.keyboard.as_deref(),
|
||||||
"all keyboards",
|
"all keyboards",
|
||||||
);
|
);
|
||||||
sync_input_device_combo(
|
sync_input_device_combo(
|
||||||
&mouse_combo,
|
&mouse_combo,
|
||||||
&catalog.mice,
|
&fresh_catalog.mice,
|
||||||
state_snapshot.devices.mouse.as_deref(),
|
state_snapshot.devices.mouse.as_deref(),
|
||||||
"all mice",
|
"all mice",
|
||||||
);
|
);
|
||||||
@ -1341,6 +1425,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
widgets_handle
|
widgets_handle
|
||||||
.status_label
|
.status_label
|
||||||
.set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}"));
|
.set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}"));
|
||||||
|
} else if let Err(err) =
|
||||||
|
tests.borrow_mut().set_camera_quality(state_snapshot.camera_quality)
|
||||||
|
{
|
||||||
|
widgets_handle.status_label.set_text(&format!(
|
||||||
|
"Device refresh succeeded, but the webcam quality test could not switch cleanly: {err}"
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
let message = if usb_audio_kernel_support_missing() {
|
let message = if usb_audio_kernel_support_missing() {
|
||||||
"Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker."
|
"Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker."
|
||||||
@ -1365,6 +1455,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
let server_entry = server_entry.clone();
|
let server_entry = server_entry.clone();
|
||||||
let camera_combo = camera_combo.clone();
|
let camera_combo = camera_combo.clone();
|
||||||
|
let camera_quality_combo = camera_quality_combo.clone();
|
||||||
let microphone_combo = microphone_combo.clone();
|
let microphone_combo = microphone_combo.clone();
|
||||||
let speaker_combo = speaker_combo.clone();
|
let speaker_combo = speaker_combo.clone();
|
||||||
let input_control_path = Rc::clone(&input_control_path);
|
let input_control_path = Rc::clone(&input_control_path);
|
||||||
@ -1433,6 +1524,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
{
|
{
|
||||||
let mut state = state.borrow_mut();
|
let mut state = state.borrow_mut();
|
||||||
state.select_camera(selected_combo_value(&camera_combo));
|
state.select_camera(selected_combo_value(&camera_combo));
|
||||||
|
state.select_camera_quality(selected_camera_quality(&camera_quality_combo));
|
||||||
state.select_microphone(selected_combo_value(µphone_combo));
|
state.select_microphone(selected_combo_value(µphone_combo));
|
||||||
state.select_speaker(selected_combo_value(&speaker_combo));
|
state.select_speaker(selected_combo_value(&speaker_combo));
|
||||||
state.select_keyboard(selected_combo_value(&keyboard_combo));
|
state.select_keyboard(selected_combo_value(&keyboard_combo));
|
||||||
@ -1729,13 +1821,16 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
let tests = Rc::clone(&tests);
|
let tests = Rc::clone(&tests);
|
||||||
let camera_combo = camera_combo.clone();
|
let camera_combo = camera_combo.clone();
|
||||||
|
let camera_quality_combo = camera_quality_combo.clone();
|
||||||
let camera_test_button = widgets.camera_test_button.clone();
|
let camera_test_button = widgets.camera_test_button.clone();
|
||||||
let widgets_handle = widgets.clone();
|
let widgets_handle = widgets.clone();
|
||||||
camera_test_button.connect_clicked(move |_| {
|
camera_test_button.connect_clicked(move |_| {
|
||||||
let selected = selected_combo_value(&camera_combo);
|
let selected = selected_combo_value(&camera_combo);
|
||||||
|
let quality = selected_camera_quality(&camera_quality_combo);
|
||||||
let result = {
|
let result = {
|
||||||
let mut tests = tests.borrow_mut();
|
let mut tests = tests.borrow_mut();
|
||||||
let _ = tests.set_camera_selection(selected.as_deref());
|
let _ = tests.set_camera_selection(selected.as_deref());
|
||||||
|
let _ = tests.set_camera_quality(quality);
|
||||||
tests.toggle_camera()
|
tests.toggle_camera()
|
||||||
};
|
};
|
||||||
update_test_action_result(
|
update_test_action_result(
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use evdev::Device;
|
|||||||
use gtk::{pango, prelude::*};
|
use gtk::{pango, prelude::*};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
devices::DeviceCatalog,
|
devices::{CameraMode, DeviceCatalog},
|
||||||
diagnostics::DiagnosticsLog,
|
diagnostics::DiagnosticsLog,
|
||||||
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
|
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
|
||||||
state::{
|
state::{
|
||||||
@ -67,6 +67,7 @@ pub struct LauncherWidgets {
|
|||||||
pub server_entry: gtk::Entry,
|
pub server_entry: gtk::Entry,
|
||||||
pub start_button: gtk::Button,
|
pub start_button: gtk::Button,
|
||||||
pub camera_combo: gtk::ComboBoxText,
|
pub camera_combo: gtk::ComboBoxText,
|
||||||
|
pub camera_quality_combo: gtk::ComboBoxText,
|
||||||
pub microphone_combo: gtk::ComboBoxText,
|
pub microphone_combo: gtk::ComboBoxText,
|
||||||
pub speaker_combo: gtk::ComboBoxText,
|
pub speaker_combo: gtk::ComboBoxText,
|
||||||
pub keyboard_combo: gtk::ComboBoxText,
|
pub keyboard_combo: gtk::ComboBoxText,
|
||||||
@ -108,6 +109,7 @@ pub struct LauncherView {
|
|||||||
pub window: gtk::ApplicationWindow,
|
pub window: gtk::ApplicationWindow,
|
||||||
pub server_entry: gtk::Entry,
|
pub server_entry: gtk::Entry,
|
||||||
pub camera_combo: gtk::ComboBoxText,
|
pub camera_combo: gtk::ComboBoxText,
|
||||||
|
pub camera_quality_combo: gtk::ComboBoxText,
|
||||||
pub microphone_combo: gtk::ComboBoxText,
|
pub microphone_combo: gtk::ComboBoxText,
|
||||||
pub speaker_combo: gtk::ComboBoxText,
|
pub speaker_combo: gtk::ComboBoxText,
|
||||||
pub keyboard_combo: gtk::ComboBoxText,
|
pub keyboard_combo: gtk::ComboBoxText,
|
||||||
@ -123,12 +125,12 @@ pub struct LauncherView {
|
|||||||
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
|
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
|
||||||
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
||||||
const LAUNCHER_DEFAULT_WIDTH: i32 = 1360;
|
const LAUNCHER_DEFAULT_WIDTH: i32 = 1360;
|
||||||
const LAUNCHER_DEFAULT_HEIGHT: i32 = 820;
|
const LAUNCHER_DEFAULT_HEIGHT: i32 = 940;
|
||||||
const OPERATIONS_RAIL_WIDTH: i32 = 288;
|
const OPERATIONS_RAIL_WIDTH: i32 = 288;
|
||||||
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
|
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
|
||||||
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
|
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
|
||||||
const EYE_PREVIEW_MIN_HEIGHT: i32 = 258;
|
const EYE_PREVIEW_MIN_HEIGHT: i32 = 320;
|
||||||
const EYE_PREVIEW_MIN_WIDTH: i32 = 460;
|
const EYE_PREVIEW_MIN_WIDTH: i32 = 568;
|
||||||
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
|
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
|
||||||
|
|
||||||
pub fn build_launcher_view(
|
pub fn build_launcher_view(
|
||||||
@ -244,6 +246,16 @@ pub fn build_launcher_view(
|
|||||||
&catalog.cameras,
|
&catalog.cameras,
|
||||||
state.devices.camera.as_deref(),
|
state.devices.camera.as_deref(),
|
||||||
);
|
);
|
||||||
|
let camera_quality_combo = gtk::ComboBoxText::new();
|
||||||
|
sync_camera_quality_combo(
|
||||||
|
&camera_quality_combo,
|
||||||
|
&state.camera_quality_options(catalog),
|
||||||
|
state.selected_camera_quality(catalog),
|
||||||
|
);
|
||||||
|
camera_quality_combo.set_size_request(88, -1);
|
||||||
|
camera_quality_combo.set_tooltip_text(Some(
|
||||||
|
"Choose the webcam quality Lesavka should capture and send to the relay host.",
|
||||||
|
));
|
||||||
let camera_test_button = gtk::Button::with_label("Start Preview");
|
let camera_test_button = gtk::Button::with_label("Start Preview");
|
||||||
stabilize_button(&camera_test_button, 118);
|
stabilize_button(&camera_test_button, 118);
|
||||||
camera_test_button.set_tooltip_text(Some(
|
camera_test_button.set_tooltip_text(Some(
|
||||||
@ -292,14 +304,82 @@ pub fn build_launcher_view(
|
|||||||
media_grid.set_row_spacing(10);
|
media_grid.set_row_spacing(10);
|
||||||
media_grid.set_column_spacing(8);
|
media_grid.set_column_spacing(8);
|
||||||
media_group.append(&media_grid);
|
media_group.append(&media_grid);
|
||||||
|
let camera_channel_toggle = gtk::CheckButton::with_label("Camera");
|
||||||
|
camera_channel_toggle.set_active(state.channels.camera);
|
||||||
|
camera_channel_toggle.set_tooltip_text(Some(
|
||||||
|
"Include the local webcam uplink in the next relay session.",
|
||||||
|
));
|
||||||
|
let audio_channel_toggle = gtk::CheckButton::with_label("Speaker");
|
||||||
|
audio_channel_toggle.set_active(state.channels.audio);
|
||||||
|
audio_channel_toggle.set_tooltip_text(Some(
|
||||||
|
"Play remote audio on this client during the next relay session.",
|
||||||
|
));
|
||||||
|
let microphone_channel_toggle = gtk::CheckButton::with_label("Mic");
|
||||||
|
microphone_channel_toggle.set_active(state.channels.microphone);
|
||||||
|
microphone_channel_toggle.set_tooltip_text(Some(
|
||||||
|
"Include the local microphone uplink in the next relay session.",
|
||||||
|
));
|
||||||
|
|
||||||
|
let audio_gain_adjustment = gtk::Adjustment::new(
|
||||||
|
state.audio_gain_percent as f64,
|
||||||
|
0.0,
|
||||||
|
super::state::MAX_AUDIO_GAIN_PERCENT as f64,
|
||||||
|
25.0,
|
||||||
|
100.0,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
let audio_gain_scale =
|
||||||
|
gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment));
|
||||||
|
audio_gain_scale.set_draw_value(false);
|
||||||
|
audio_gain_scale.set_hexpand(false);
|
||||||
|
audio_gain_scale.set_size_request(96, -1);
|
||||||
|
audio_gain_scale.set_tooltip_text(Some(
|
||||||
|
"Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.",
|
||||||
|
));
|
||||||
|
let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label()));
|
||||||
|
audio_gain_value.set_visible(false);
|
||||||
|
|
||||||
|
let mic_gain_adjustment = gtk::Adjustment::new(
|
||||||
|
state.mic_gain_percent as f64,
|
||||||
|
0.0,
|
||||||
|
super::state::MAX_MIC_GAIN_PERCENT as f64,
|
||||||
|
25.0,
|
||||||
|
100.0,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment));
|
||||||
|
mic_gain_scale.set_draw_value(false);
|
||||||
|
mic_gain_scale.set_hexpand(false);
|
||||||
|
mic_gain_scale.set_size_request(96, -1);
|
||||||
|
mic_gain_scale.set_tooltip_text(Some(
|
||||||
|
"Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.",
|
||||||
|
));
|
||||||
|
let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label()));
|
||||||
|
mic_gain_value.set_visible(false);
|
||||||
|
|
||||||
camera_combo.set_size_request(0, -1);
|
camera_combo.set_size_request(0, -1);
|
||||||
|
let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
camera_combo.set_hexpand(true);
|
||||||
|
camera_quality_combo.set_hexpand(false);
|
||||||
|
camera_selectors.append(&camera_combo);
|
||||||
|
camera_selectors.append(&camera_quality_combo);
|
||||||
speaker_combo.set_size_request(0, -1);
|
speaker_combo.set_size_request(0, -1);
|
||||||
attach_device_row(&media_grid, 0, "Camera", &camera_combo, &camera_test_button);
|
attach_device_control_row(
|
||||||
attach_device_row(
|
&media_grid,
|
||||||
|
0,
|
||||||
|
&camera_channel_toggle,
|
||||||
|
&camera_selectors,
|
||||||
|
&camera_test_button,
|
||||||
|
);
|
||||||
|
let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
speaker_combo.set_hexpand(true);
|
||||||
|
speaker_selectors.append(&speaker_combo);
|
||||||
|
speaker_selectors.append(&audio_gain_scale);
|
||||||
|
attach_device_control_row(
|
||||||
&media_grid,
|
&media_grid,
|
||||||
1,
|
1,
|
||||||
"Speaker",
|
&audio_channel_toggle,
|
||||||
&speaker_combo,
|
&speaker_selectors,
|
||||||
&speaker_test_button,
|
&speaker_test_button,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -315,11 +395,15 @@ pub fn build_launcher_view(
|
|||||||
"Monitor the selected microphone through the selected speaker until you stop the test.",
|
"Monitor the selected microphone through the selected speaker until you stop the test.",
|
||||||
));
|
));
|
||||||
microphone_combo.set_size_request(0, -1);
|
microphone_combo.set_size_request(0, -1);
|
||||||
attach_device_row(
|
let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
microphone_combo.set_hexpand(true);
|
||||||
|
microphone_selectors.append(µphone_combo);
|
||||||
|
microphone_selectors.append(&mic_gain_scale);
|
||||||
|
attach_device_control_row(
|
||||||
&media_grid,
|
&media_grid,
|
||||||
2,
|
2,
|
||||||
"Microphone",
|
µphone_channel_toggle,
|
||||||
µphone_combo,
|
µphone_selectors,
|
||||||
µphone_test_button,
|
µphone_test_button,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -464,37 +548,6 @@ pub fn build_launcher_view(
|
|||||||
live_actions_row.append(&usb_recover_button);
|
live_actions_row.append(&usb_recover_button);
|
||||||
connection_body.append(&live_actions_row);
|
connection_body.append(&live_actions_row);
|
||||||
|
|
||||||
let channel_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
||||||
channel_row.set_hexpand(true);
|
|
||||||
let channel_heading = gtk::Label::new(Some("Streams"));
|
|
||||||
channel_heading.add_css_class("subgroup-title");
|
|
||||||
channel_heading.set_halign(gtk::Align::Start);
|
|
||||||
channel_heading.set_width_chars(10);
|
|
||||||
channel_row.append(&channel_heading);
|
|
||||||
let channel_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
||||||
channel_buttons.set_hexpand(true);
|
|
||||||
channel_buttons.set_homogeneous(true);
|
|
||||||
let camera_channel_toggle = gtk::CheckButton::with_label("Webcam");
|
|
||||||
camera_channel_toggle.set_active(state.channels.camera);
|
|
||||||
camera_channel_toggle.set_tooltip_text(Some(
|
|
||||||
"Include the local webcam uplink in the next relay session.",
|
|
||||||
));
|
|
||||||
let microphone_channel_toggle = gtk::CheckButton::with_label("Mic");
|
|
||||||
microphone_channel_toggle.set_active(state.channels.microphone);
|
|
||||||
microphone_channel_toggle.set_tooltip_text(Some(
|
|
||||||
"Include the local microphone uplink in the next relay session.",
|
|
||||||
));
|
|
||||||
let audio_channel_toggle = gtk::CheckButton::with_label("Audio");
|
|
||||||
audio_channel_toggle.set_active(state.channels.audio);
|
|
||||||
audio_channel_toggle.set_tooltip_text(Some(
|
|
||||||
"Play remote audio on this client during the next relay session.",
|
|
||||||
));
|
|
||||||
channel_buttons.append(&camera_channel_toggle);
|
|
||||||
channel_buttons.append(µphone_channel_toggle);
|
|
||||||
channel_buttons.append(&audio_channel_toggle);
|
|
||||||
channel_row.append(&channel_buttons);
|
|
||||||
connection_body.append(&channel_row);
|
|
||||||
|
|
||||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||||
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
||||||
power_heading.add_css_class("subgroup-title");
|
power_heading.add_css_class("subgroup-title");
|
||||||
@ -529,66 +582,7 @@ pub fn build_launcher_view(
|
|||||||
power_buttons.append(&power_auto_button);
|
power_buttons.append(&power_auto_button);
|
||||||
power_buttons.append(&power_off_button);
|
power_buttons.append(&power_off_button);
|
||||||
power_row.append(&power_buttons);
|
power_row.append(&power_buttons);
|
||||||
let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
||||||
audio_gain_row.set_size_request(220, -1);
|
|
||||||
audio_gain_row.set_hexpand(true);
|
|
||||||
let audio_gain_label = gtk::Label::new(Some("Audio"));
|
|
||||||
audio_gain_label.add_css_class("dim-label");
|
|
||||||
audio_gain_label.set_halign(gtk::Align::Start);
|
|
||||||
audio_gain_label.set_width_chars(10);
|
|
||||||
let audio_gain_adjustment = gtk::Adjustment::new(
|
|
||||||
state.audio_gain_percent as f64,
|
|
||||||
0.0,
|
|
||||||
super::state::MAX_AUDIO_GAIN_PERCENT as f64,
|
|
||||||
25.0,
|
|
||||||
100.0,
|
|
||||||
0.0,
|
|
||||||
);
|
|
||||||
let audio_gain_scale =
|
|
||||||
gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment));
|
|
||||||
audio_gain_scale.set_draw_value(false);
|
|
||||||
audio_gain_scale.set_hexpand(true);
|
|
||||||
audio_gain_scale.set_tooltip_text(Some(
|
|
||||||
"Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.",
|
|
||||||
));
|
|
||||||
let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label()));
|
|
||||||
audio_gain_value.add_css_class("dim-label");
|
|
||||||
audio_gain_value.set_width_chars(5);
|
|
||||||
audio_gain_value.set_xalign(1.0);
|
|
||||||
audio_gain_row.append(&audio_gain_label);
|
|
||||||
audio_gain_row.append(&audio_gain_scale);
|
|
||||||
audio_gain_row.append(&audio_gain_value);
|
|
||||||
let mic_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
||||||
mic_gain_row.set_size_request(220, -1);
|
|
||||||
mic_gain_row.set_hexpand(true);
|
|
||||||
let mic_gain_label = gtk::Label::new(Some("Mic Gain"));
|
|
||||||
mic_gain_label.add_css_class("dim-label");
|
|
||||||
mic_gain_label.set_halign(gtk::Align::Start);
|
|
||||||
mic_gain_label.set_width_chars(10);
|
|
||||||
let mic_gain_adjustment = gtk::Adjustment::new(
|
|
||||||
state.mic_gain_percent as f64,
|
|
||||||
0.0,
|
|
||||||
super::state::MAX_MIC_GAIN_PERCENT as f64,
|
|
||||||
25.0,
|
|
||||||
100.0,
|
|
||||||
0.0,
|
|
||||||
);
|
|
||||||
let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment));
|
|
||||||
mic_gain_scale.set_draw_value(false);
|
|
||||||
mic_gain_scale.set_hexpand(true);
|
|
||||||
mic_gain_scale.set_tooltip_text(Some(
|
|
||||||
"Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.",
|
|
||||||
));
|
|
||||||
let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label()));
|
|
||||||
mic_gain_value.add_css_class("dim-label");
|
|
||||||
mic_gain_value.set_width_chars(5);
|
|
||||||
mic_gain_value.set_xalign(1.0);
|
|
||||||
mic_gain_row.append(&mic_gain_label);
|
|
||||||
mic_gain_row.append(&mic_gain_scale);
|
|
||||||
mic_gain_row.append(&mic_gain_value);
|
|
||||||
power_shell.append(&power_row);
|
power_shell.append(&power_row);
|
||||||
power_shell.append(&audio_gain_row);
|
|
||||||
power_shell.append(&mic_gain_row);
|
|
||||||
connection_body.append(&power_shell);
|
connection_body.append(&power_shell);
|
||||||
let routing_heading = gtk::Label::new(Some("Inputs"));
|
let routing_heading = gtk::Label::new(Some("Inputs"));
|
||||||
routing_heading.add_css_class("subgroup-title");
|
routing_heading.add_css_class("subgroup-title");
|
||||||
@ -830,6 +824,7 @@ pub fn build_launcher_view(
|
|||||||
server_entry: server_entry.clone(),
|
server_entry: server_entry.clone(),
|
||||||
start_button: start_button.clone(),
|
start_button: start_button.clone(),
|
||||||
camera_combo: camera_combo.clone(),
|
camera_combo: camera_combo.clone(),
|
||||||
|
camera_quality_combo: camera_quality_combo.clone(),
|
||||||
microphone_combo: microphone_combo.clone(),
|
microphone_combo: microphone_combo.clone(),
|
||||||
speaker_combo: speaker_combo.clone(),
|
speaker_combo: speaker_combo.clone(),
|
||||||
keyboard_combo: keyboard_combo.clone(),
|
keyboard_combo: keyboard_combo.clone(),
|
||||||
@ -872,6 +867,7 @@ pub fn build_launcher_view(
|
|||||||
window,
|
window,
|
||||||
server_entry,
|
server_entry,
|
||||||
camera_combo,
|
camera_combo,
|
||||||
|
camera_quality_combo,
|
||||||
microphone_combo,
|
microphone_combo,
|
||||||
speaker_combo,
|
speaker_combo,
|
||||||
keyboard_combo,
|
keyboard_combo,
|
||||||
@ -1207,6 +1203,29 @@ pub fn sync_stage_device_combo(
|
|||||||
set_stage_combo_active_text(combo, selected);
|
set_stage_combo_active_text(combo, selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sync_camera_quality_combo(
|
||||||
|
combo: >k::ComboBoxText,
|
||||||
|
options: &[CameraMode],
|
||||||
|
selected: Option<CameraMode>,
|
||||||
|
) {
|
||||||
|
combo.remove_all();
|
||||||
|
if options.is_empty() {
|
||||||
|
combo.append(Some("none"), "Quality");
|
||||||
|
combo.set_active_id(Some("none"));
|
||||||
|
combo.set_sensitive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for option in options {
|
||||||
|
combo.append(Some(&option.id()), &option.short_label());
|
||||||
|
}
|
||||||
|
let active = selected
|
||||||
|
.filter(|mode| options.contains(mode))
|
||||||
|
.or_else(|| options.first().copied())
|
||||||
|
.map(CameraMode::id);
|
||||||
|
combo.set_active_id(active.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sync_input_device_combo(
|
pub fn sync_input_device_combo(
|
||||||
combo: >k::ComboBoxText,
|
combo: >k::ComboBoxText,
|
||||||
values: &[String],
|
values: &[String],
|
||||||
@ -1221,18 +1240,17 @@ pub fn sync_input_device_combo(
|
|||||||
super::ui_runtime::set_combo_active_text(combo, selected);
|
super::ui_runtime::set_combo_active_text(combo, selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn attach_device_row(
|
fn attach_device_control_row(
|
||||||
grid: >k::Grid,
|
grid: >k::Grid,
|
||||||
row: i32,
|
row: i32,
|
||||||
label: &str,
|
stream_toggle: >k::CheckButton,
|
||||||
combo: >k::ComboBoxText,
|
selector: &impl IsA<gtk::Widget>,
|
||||||
test_button: >k::Button,
|
test_button: >k::Button,
|
||||||
) {
|
) {
|
||||||
let label_widget = gtk::Label::new(Some(label));
|
stream_toggle.set_halign(gtk::Align::Start);
|
||||||
label_widget.set_halign(gtk::Align::Start);
|
selector.set_hexpand(true);
|
||||||
combo.set_hexpand(true);
|
grid.attach(stream_toggle, 0, row, 1, 1);
|
||||||
grid.attach(&label_widget, 0, row, 1, 1);
|
grid.attach(selector, 1, row, 1, 1);
|
||||||
grid.attach(combo, 1, row, 1, 1);
|
|
||||||
grid.attach(test_button, 2, row, 1, 1);
|
grid.attach(test_button, 2, row, 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -130,12 +130,21 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
|||||||
widgets
|
widgets
|
||||||
.camera_combo
|
.camera_combo
|
||||||
.set_sensitive(!relay_live && state.channels.camera);
|
.set_sensitive(!relay_live && state.channels.camera);
|
||||||
|
widgets.camera_quality_combo.set_sensitive(
|
||||||
|
!relay_live
|
||||||
|
&& state.channels.camera
|
||||||
|
&& state.devices.camera.is_some()
|
||||||
|
&& state.camera_quality.is_some(),
|
||||||
|
);
|
||||||
widgets
|
widgets
|
||||||
.microphone_combo
|
.microphone_combo
|
||||||
.set_sensitive(!relay_live && state.channels.microphone);
|
.set_sensitive(!relay_live && state.channels.microphone);
|
||||||
widgets
|
widgets
|
||||||
.speaker_combo
|
.speaker_combo
|
||||||
.set_sensitive(!relay_live && state.channels.audio);
|
.set_sensitive(!relay_live && state.channels.audio);
|
||||||
|
widgets
|
||||||
|
.audio_gain_scale
|
||||||
|
.set_sensitive(!relay_live && state.channels.audio);
|
||||||
widgets.keyboard_combo.set_sensitive(!relay_live);
|
widgets.keyboard_combo.set_sensitive(!relay_live);
|
||||||
widgets.mouse_combo.set_sensitive(!relay_live);
|
widgets.mouse_combo.set_sensitive(!relay_live);
|
||||||
widgets.camera_channel_toggle.set_sensitive(!relay_live);
|
widgets.camera_channel_toggle.set_sensitive(!relay_live);
|
||||||
@ -147,6 +156,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
|||||||
widgets
|
widgets
|
||||||
.microphone_test_button
|
.microphone_test_button
|
||||||
.set_sensitive(!relay_live && state.channels.microphone);
|
.set_sensitive(!relay_live && state.channels.microphone);
|
||||||
|
widgets
|
||||||
|
.mic_gain_scale
|
||||||
|
.set_sensitive(!relay_live && state.channels.microphone);
|
||||||
widgets
|
widgets
|
||||||
.speaker_test_button
|
.speaker_test_button
|
||||||
.set_sensitive(!relay_live && state.channels.audio);
|
.set_sensitive(!relay_live && state.channels.audio);
|
||||||
|
|||||||
@ -41,5 +41,8 @@ pub fn pick_h264_decoder() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn buildable_decoder(name: &str) -> bool {
|
fn buildable_decoder(name: &str) -> bool {
|
||||||
|
if gst::init().is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok()
|
gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.11.48"
|
version = "0.12.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"clippy_warnings": 40,
|
"clippy_warnings": 40,
|
||||||
"doc_debt": 13,
|
"doc_debt": 13,
|
||||||
"loc": 808
|
"loc": 816
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -23,7 +23,7 @@
|
|||||||
"client/src/input/camera.rs": {
|
"client/src/input/camera.rs": {
|
||||||
"clippy_warnings": 14,
|
"clippy_warnings": 14,
|
||||||
"doc_debt": 10,
|
"doc_debt": 10,
|
||||||
"loc": 719
|
"loc": 717
|
||||||
},
|
},
|
||||||
"client/src/input/inputs.rs": {
|
"client/src/input/inputs.rs": {
|
||||||
"clippy_warnings": 40,
|
"clippy_warnings": 40,
|
||||||
@ -61,14 +61,14 @@
|
|||||||
"loc": 178
|
"loc": 178
|
||||||
},
|
},
|
||||||
"client/src/launcher/device_test.rs": {
|
"client/src/launcher/device_test.rs": {
|
||||||
"clippy_warnings": 67,
|
"clippy_warnings": 75,
|
||||||
"doc_debt": 40,
|
"doc_debt": 43,
|
||||||
"loc": 1148
|
"loc": 1219
|
||||||
},
|
},
|
||||||
"client/src/launcher/devices.rs": {
|
"client/src/launcher/devices.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 25,
|
||||||
"doc_debt": 14,
|
"doc_debt": 19,
|
||||||
"loc": 400
|
"loc": 564
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"clippy_warnings": 92,
|
"clippy_warnings": 92,
|
||||||
@ -78,7 +78,7 @@
|
|||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
"doc_debt": 8,
|
"doc_debt": 8,
|
||||||
"loc": 480
|
"loc": 497
|
||||||
},
|
},
|
||||||
"client/src/launcher/power.rs": {
|
"client/src/launcher/power.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -91,24 +91,24 @@
|
|||||||
"loc": 2216
|
"loc": 2216
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"clippy_warnings": 168,
|
"clippy_warnings": 172,
|
||||||
"doc_debt": 57,
|
"doc_debt": 58,
|
||||||
"loc": 1478
|
"loc": 1562
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"clippy_warnings": 68,
|
"clippy_warnings": 70,
|
||||||
"doc_debt": 23,
|
"doc_debt": 23,
|
||||||
"loc": 2524
|
"loc": 2619
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components.rs": {
|
"client/src/launcher/ui_components.rs": {
|
||||||
"clippy_warnings": 22,
|
"clippy_warnings": 22,
|
||||||
"doc_debt": 17,
|
"doc_debt": 18,
|
||||||
"loc": 1497
|
"loc": 1515
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime.rs": {
|
"client/src/launcher/ui_runtime.rs": {
|
||||||
"clippy_warnings": 74,
|
"clippy_warnings": 74,
|
||||||
"doc_debt": 44,
|
"doc_debt": 44,
|
||||||
"loc": 1790
|
"loc": 1802
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 6,
|
||||||
@ -157,8 +157,8 @@
|
|||||||
},
|
},
|
||||||
"client/src/video_support.rs": {
|
"client/src/video_support.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 2,
|
||||||
"loc": 45
|
"loc": 48
|
||||||
},
|
},
|
||||||
"common/src/bin/cli.rs": {
|
"common/src/bin/cli.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -196,9 +196,9 @@
|
|||||||
"loc": 105
|
"loc": 105
|
||||||
},
|
},
|
||||||
"server/src/audio.rs": {
|
"server/src/audio.rs": {
|
||||||
"clippy_warnings": 43,
|
"clippy_warnings": 47,
|
||||||
"doc_debt": 13,
|
"doc_debt": 15,
|
||||||
"loc": 680
|
"loc": 737
|
||||||
},
|
},
|
||||||
"server/src/bin/lesavka-uvc.real.inc": {
|
"server/src/bin/lesavka-uvc.real.inc": {
|
||||||
"clippy_warnings": 33,
|
"clippy_warnings": 33,
|
||||||
@ -211,14 +211,14 @@
|
|||||||
"loc": 712
|
"loc": 712
|
||||||
},
|
},
|
||||||
"server/src/camera.rs": {
|
"server/src/camera.rs": {
|
||||||
"clippy_warnings": 12,
|
"clippy_warnings": 18,
|
||||||
"doc_debt": 11,
|
"doc_debt": 19,
|
||||||
"loc": 392
|
"loc": 623
|
||||||
},
|
},
|
||||||
"server/src/camera_runtime.rs": {
|
"server/src/camera_runtime.rs": {
|
||||||
"clippy_warnings": 10,
|
"clippy_warnings": 10,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 200
|
"loc": 204
|
||||||
},
|
},
|
||||||
"server/src/capture_power.rs": {
|
"server/src/capture_power.rs": {
|
||||||
"clippy_warnings": 12,
|
"clippy_warnings": 12,
|
||||||
@ -276,9 +276,9 @@
|
|||||||
"loc": 844
|
"loc": 844
|
||||||
},
|
},
|
||||||
"server/src/video_sinks.rs": {
|
"server/src/video_sinks.rs": {
|
||||||
"clippy_warnings": 78,
|
"clippy_warnings": 80,
|
||||||
"doc_debt": 11,
|
"doc_debt": 15,
|
||||||
"loc": 574
|
"loc": 679
|
||||||
},
|
},
|
||||||
"server/src/video_support.rs": {
|
"server/src/video_support.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
|
|||||||
@ -2,14 +2,14 @@
|
|||||||
"files": {
|
"files": {
|
||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"line_percent": 97.4,
|
"line_percent": 97.4,
|
||||||
"loc": 808
|
"loc": 816
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 132
|
"loc": 132
|
||||||
},
|
},
|
||||||
"client/src/bin/lesavka-relayctl.rs": {
|
"client/src/bin/lesavka-relayctl.rs": {
|
||||||
"line_percent": 0.0,
|
"line_percent": 25.24,
|
||||||
"loc": 140
|
"loc": 140
|
||||||
},
|
},
|
||||||
"client/src/handshake.rs": {
|
"client/src/handshake.rs": {
|
||||||
@ -17,8 +17,8 @@
|
|||||||
"loc": 381
|
"loc": 381
|
||||||
},
|
},
|
||||||
"client/src/input/camera.rs": {
|
"client/src/input/camera.rs": {
|
||||||
"line_percent": 95.24,
|
"line_percent": 96.51,
|
||||||
"loc": 719
|
"loc": 717
|
||||||
},
|
},
|
||||||
"client/src/input/inputs.rs": {
|
"client/src/input/inputs.rs": {
|
||||||
"line_percent": 96.39,
|
"line_percent": 96.39,
|
||||||
@ -45,24 +45,24 @@
|
|||||||
"loc": 178
|
"loc": 178
|
||||||
},
|
},
|
||||||
"client/src/launcher/devices.rs": {
|
"client/src/launcher/devices.rs": {
|
||||||
"line_percent": 95.93,
|
"line_percent": 96.0,
|
||||||
"loc": 400
|
"loc": 564
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"line_percent": 84.3,
|
"line_percent": 84.3,
|
||||||
"loc": 1021
|
"loc": 1021
|
||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"line_percent": 84.2,
|
"line_percent": 84.85,
|
||||||
"loc": 480
|
"loc": 497
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"line_percent": 85.06,
|
"line_percent": 86.04,
|
||||||
"loc": 1478
|
"loc": 1562
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 2524
|
"loc": 2619
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"line_percent": 97.73,
|
"line_percent": 97.73,
|
||||||
@ -73,7 +73,7 @@
|
|||||||
"loc": 100
|
"loc": 100
|
||||||
},
|
},
|
||||||
"client/src/output/audio.rs": {
|
"client/src/output/audio.rs": {
|
||||||
"line_percent": 76.92,
|
"line_percent": 89.42,
|
||||||
"loc": 371
|
"loc": 371
|
||||||
},
|
},
|
||||||
"client/src/output/display.rs": {
|
"client/src/output/display.rs": {
|
||||||
@ -93,8 +93,8 @@
|
|||||||
"loc": 82
|
"loc": 82
|
||||||
},
|
},
|
||||||
"client/src/video_support.rs": {
|
"client/src/video_support.rs": {
|
||||||
"line_percent": 0.0,
|
"line_percent": 83.87,
|
||||||
"loc": 45
|
"loc": 48
|
||||||
},
|
},
|
||||||
"common/src/bin/cli.rs": {
|
"common/src/bin/cli.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
@ -125,20 +125,20 @@
|
|||||||
"loc": 105
|
"loc": 105
|
||||||
},
|
},
|
||||||
"server/src/audio.rs": {
|
"server/src/audio.rs": {
|
||||||
"line_percent": 98.97,
|
"line_percent": 96.88,
|
||||||
"loc": 680
|
"loc": 737
|
||||||
},
|
},
|
||||||
"server/src/bin/lesavka-uvc.rs": {
|
"server/src/bin/lesavka-uvc.rs": {
|
||||||
"line_percent": 95.92,
|
"line_percent": 95.92,
|
||||||
"loc": 712
|
"loc": 712
|
||||||
},
|
},
|
||||||
"server/src/camera.rs": {
|
"server/src/camera.rs": {
|
||||||
"line_percent": 99.1,
|
"line_percent": 96.6,
|
||||||
"loc": 392
|
"loc": 623
|
||||||
},
|
},
|
||||||
"server/src/camera_runtime.rs": {
|
"server/src/camera_runtime.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 200
|
"loc": 204
|
||||||
},
|
},
|
||||||
"server/src/capture_power.rs": {
|
"server/src/capture_power.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
@ -173,8 +173,8 @@
|
|||||||
"loc": 844
|
"loc": 844
|
||||||
},
|
},
|
||||||
"server/src/video_sinks.rs": {
|
"server/src/video_sinks.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 95.8,
|
||||||
"loc": 574
|
"loc": 679
|
||||||
},
|
},
|
||||||
"server/src/video_support.rs": {
|
"server/src/video_support.rs": {
|
||||||
"line_percent": 97.62,
|
"line_percent": 97.62,
|
||||||
|
|||||||
@ -27,8 +27,14 @@ build_url=${BUILD_URL:-}
|
|||||||
start_seconds=$(date +%s)
|
start_seconds=$(date +%s)
|
||||||
status=0
|
status=0
|
||||||
set +e
|
set +e
|
||||||
cargo test --workspace --all-targets --color never 2>&1 | tee "${TEST_LOG}"
|
cargo build --workspace --bins --color never 2>&1 | tee "${TEST_LOG}"
|
||||||
status=${PIPESTATUS[0]}
|
build_status=${PIPESTATUS[0]}
|
||||||
|
if [[ "${build_status}" -eq 0 ]]; then
|
||||||
|
cargo test --workspace --all-targets --color never 2>&1 | tee -a "${TEST_LOG}"
|
||||||
|
status=${PIPESTATUS[0]}
|
||||||
|
else
|
||||||
|
status=${build_status}
|
||||||
|
fi
|
||||||
set -e
|
set -e
|
||||||
end_seconds=$(date +%s)
|
end_seconds=$(date +%s)
|
||||||
duration_seconds=$((end_seconds - start_seconds))
|
duration_seconds=$((end_seconds - start_seconds))
|
||||||
|
|||||||
@ -423,7 +423,15 @@ fi
|
|||||||
echo "# Edit only for local hardware overrides; rerunning the installer refreshes defaults."
|
echo "# Edit only for local hardware overrides; rerunning the installer refreshes defaults."
|
||||||
if [[ -n $HDMI_CONNECTOR ]]; then
|
if [[ -n $HDMI_CONNECTOR ]]; then
|
||||||
printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR"
|
printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR"
|
||||||
|
printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_CAM_OUTPUT:-hdmi}"
|
||||||
fi
|
fi
|
||||||
|
printf 'LESAVKA_CAM_WIDTH=%s\n' "${LESAVKA_CAM_WIDTH:-1920}"
|
||||||
|
printf 'LESAVKA_CAM_HEIGHT=%s\n' "${LESAVKA_CAM_HEIGHT:-1080}"
|
||||||
|
printf 'LESAVKA_CAM_FPS=%s\n' "${LESAVKA_CAM_FPS:-30}"
|
||||||
|
printf 'LESAVKA_HDMI_WIDTH=%s\n' "${LESAVKA_HDMI_WIDTH:-1920}"
|
||||||
|
printf 'LESAVKA_HDMI_HEIGHT=%s\n' "${LESAVKA_HDMI_HEIGHT:-1080}"
|
||||||
|
printf 'LESAVKA_HDMI_SINK=%s\n' "${LESAVKA_HDMI_SINK:-fbdevsink}"
|
||||||
|
printf 'LESAVKA_HDMI_FBDEV=%s\n' "${LESAVKA_HDMI_FBDEV:-/dev/fb0}"
|
||||||
printf 'LESAVKA_HDMI_DRIVER=%s\n' "${LESAVKA_HDMI_DRIVER:-vc4}"
|
printf 'LESAVKA_HDMI_DRIVER=%s\n' "${LESAVKA_HDMI_DRIVER:-vc4}"
|
||||||
printf 'LESAVKA_UAC_DEV=%s\n' "${LESAVKA_UAC_DEV:-hw:UAC2Gadget,0}"
|
printf 'LESAVKA_UAC_DEV=%s\n' "${LESAVKA_UAC_DEV:-hw:UAC2Gadget,0}"
|
||||||
printf 'LESAVKA_ALSA_DEV=%s\n' "${LESAVKA_ALSA_DEV:-hw:UAC2Gadget,0}"
|
printf 'LESAVKA_ALSA_DEV=%s\n' "${LESAVKA_ALSA_DEV:-hw:UAC2Gadget,0}"
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.11.48"
|
version = "0.12.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,57 @@ impl Drop for AudioStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start_pipeline_or_reset(
|
||||||
|
pipeline: &gst::Pipeline,
|
||||||
|
context: &'static str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match pipeline.set_state(gst::State::Playing) {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(error) => {
|
||||||
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
|
Err(error).context(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn spawn_pipeline_bus_logger(bus: gst::Bus, label: &'static str, playing_message: &'static str) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for msg in bus.iter_timed(gst::ClockTime::NONE) {
|
||||||
|
match msg.view() {
|
||||||
|
Error(e) => error!(
|
||||||
|
"💥 {label} pipeline from {:?}: {} ({})",
|
||||||
|
msg.src().map(gst::prelude::GstObjectExt::path_string),
|
||||||
|
e.error(),
|
||||||
|
e.debug().unwrap_or_default()
|
||||||
|
),
|
||||||
|
Warning(w) => warn!(
|
||||||
|
"⚠️ {label} pipeline from {:?}: {} ({})",
|
||||||
|
msg.src().map(gst::prelude::GstObjectExt::path_string),
|
||||||
|
w.error(),
|
||||||
|
w.debug().unwrap_or_default()
|
||||||
|
),
|
||||||
|
StateChanged(s)
|
||||||
|
if s.current() == gst::State::Playing
|
||||||
|
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
|
||||||
|
{
|
||||||
|
debug!("{playing_message}")
|
||||||
|
}
|
||||||
|
Element(e) => {
|
||||||
|
if let Some(structure) = e.structure() {
|
||||||
|
if structure.name() == "level" {
|
||||||
|
info!("🔊 source audio level {}", structure);
|
||||||
|
} else {
|
||||||
|
debug!("🔎 audio element message: {}", structure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/*───────────────────────────────────────────────────────────────────────────*/
|
/*───────────────────────────────────────────────────────────────────────────*/
|
||||||
/* ear() - capture from ALSA (“speaker”) and push AAC AUs via gRPC */
|
/* ear() - capture from ALSA (“speaker”) and push AAC AUs via gRPC */
|
||||||
/*───────────────────────────────────────────────────────────────────────────*/
|
/*───────────────────────────────────────────────────────────────────────────*/
|
||||||
@ -104,35 +155,6 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
|
|||||||
let source_health = Arc::new(AudioSourceHealth::new());
|
let source_health = Arc::new(AudioSourceHealth::new());
|
||||||
|
|
||||||
let bus = pipeline.bus().expect("bus");
|
let bus = pipeline.bus().expect("bus");
|
||||||
std::thread::spawn(move || {
|
|
||||||
for msg in bus.iter_timed(gst::ClockTime::NONE) {
|
|
||||||
match msg.view() {
|
|
||||||
Error(e) => error!(
|
|
||||||
"💥 audio pipeline: {} ({})",
|
|
||||||
e.error(),
|
|
||||||
e.debug().unwrap_or_default()
|
|
||||||
),
|
|
||||||
Warning(w) => warn!(
|
|
||||||
"⚠️ audio pipeline: {} ({})",
|
|
||||||
w.error(),
|
|
||||||
w.debug().unwrap_or_default()
|
|
||||||
),
|
|
||||||
StateChanged(s) if s.current() == gst::State::Playing => {
|
|
||||||
debug!("🎶 audio pipeline PLAYING")
|
|
||||||
}
|
|
||||||
Element(e) => {
|
|
||||||
if let Some(structure) = e.structure() {
|
|
||||||
if structure.name() == "level" {
|
|
||||||
info!("🔊 source audio level {}", structure);
|
|
||||||
} else {
|
|
||||||
debug!("🔎 audio element message: {}", structure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/*──────────── callbacks ────────────*/
|
/*──────────── callbacks ────────────*/
|
||||||
sink.set_callbacks(
|
sink.set_callbacks(
|
||||||
@ -183,9 +205,8 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
|
|||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
pipeline
|
start_pipeline_or_reset(&pipeline, "starting audio pipeline")?;
|
||||||
.set_state(gst::State::Playing)
|
spawn_pipeline_bus_logger(bus, "audio", "🎶 audio pipeline PLAYING");
|
||||||
.context("starting audio pipeline")?;
|
|
||||||
|
|
||||||
spawn_audio_source_watchdog(
|
spawn_audio_source_watchdog(
|
||||||
pipeline.clone(),
|
pipeline.clone(),
|
||||||
@ -465,6 +486,12 @@ pub struct Voice {
|
|||||||
tap: ClipTap,
|
tap: ClipTap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for Voice {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self._pipe.set_state(gst::State::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn voice_input_caps() -> gst::Caps {
|
fn voice_input_caps() -> gst::Caps {
|
||||||
gst::Caps::builder("audio/mpeg")
|
gst::Caps::builder("audio/mpeg")
|
||||||
.field("mpegversion", 4i32)
|
.field("mpegversion", 4i32)
|
||||||
@ -494,7 +521,7 @@ impl Voice {
|
|||||||
.context("make fakesink")?;
|
.context("make fakesink")?;
|
||||||
pipeline.add_many(&[appsrc.upcast_ref(), &sink])?;
|
pipeline.add_many(&[appsrc.upcast_ref(), &sink])?;
|
||||||
gst::Element::link_many(&[appsrc.upcast_ref(), &sink])?;
|
gst::Element::link_many(&[appsrc.upcast_ref(), &sink])?;
|
||||||
pipeline.set_state(gst::State::Playing)?;
|
start_pipeline_or_reset(&pipeline, "starting voice pipeline")?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
appsrc,
|
appsrc,
|
||||||
@ -582,31 +609,6 @@ impl Voice {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let bus = pipeline.bus().context("voice pipeline bus")?;
|
let bus = pipeline.bus().context("voice pipeline bus")?;
|
||||||
std::thread::spawn(move || {
|
|
||||||
for msg in bus.iter_timed(gst::ClockTime::NONE) {
|
|
||||||
match msg.view() {
|
|
||||||
Error(e) => error!(
|
|
||||||
"🎤💥 voice pipeline from {:?}: {} ({})",
|
|
||||||
msg.src().map(gst::prelude::GstObjectExt::path_string),
|
|
||||||
e.error(),
|
|
||||||
e.debug().unwrap_or_default()
|
|
||||||
),
|
|
||||||
Warning(w) => warn!(
|
|
||||||
"🎤⚠️ voice pipeline from {:?}: {} ({})",
|
|
||||||
msg.src().map(gst::prelude::GstObjectExt::path_string),
|
|
||||||
w.error(),
|
|
||||||
w.debug().unwrap_or_default()
|
|
||||||
),
|
|
||||||
StateChanged(s)
|
|
||||||
if s.current() == gst::State::Playing
|
|
||||||
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
|
|
||||||
{
|
|
||||||
debug!("🎤 voice pipeline ▶️")
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// underrun ≠ error – just show a warning
|
// underrun ≠ error – just show a warning
|
||||||
// let _id = alsa_sink.connect("underrun", false, |_| {
|
// let _id = alsa_sink.connect("underrun", false, |_| {
|
||||||
@ -614,7 +616,8 @@ impl Voice {
|
|||||||
// None
|
// None
|
||||||
// });
|
// });
|
||||||
|
|
||||||
pipeline.set_state(gst::State::Playing)?;
|
start_pipeline_or_reset(&pipeline, "starting voice pipeline")?;
|
||||||
|
spawn_pipeline_bus_logger(bus, "voice", "🎤 voice pipeline ▶️");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
appsrc,
|
appsrc,
|
||||||
|
|||||||
@ -193,13 +193,24 @@ fn parse_camera_output(raw: &str) -> Option<CameraOutput> {
|
|||||||
|
|
||||||
fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig {
|
fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig {
|
||||||
let hw_decode = has_hw_h264_decode();
|
let hw_decode = has_hw_h264_decode();
|
||||||
let (width, height) = if hw_decode { (1920, 1080) } else { (1280, 720) };
|
let (default_width, default_height) = if hw_decode { (1920, 1080) } else { (1280, 720) };
|
||||||
let fps = 30;
|
let width = read_u32_from_env("LESAVKA_CAM_WIDTH").unwrap_or(default_width);
|
||||||
|
let height = read_u32_from_env("LESAVKA_CAM_HEIGHT").unwrap_or(default_height);
|
||||||
|
let fps = read_u32_from_env("LESAVKA_CAM_FPS").unwrap_or(30).max(1);
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
if !hw_decode {
|
if !hw_decode {
|
||||||
warn!(
|
if width == default_width && height == default_height {
|
||||||
"📷 HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink"
|
warn!(
|
||||||
);
|
"📷 HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
"📷 HDMI output: hardware H264 decoder not detected; using configured camera uplink size"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
CameraConfig {
|
CameraConfig {
|
||||||
output: CameraOutput::Hdmi,
|
output: CameraOutput::Hdmi,
|
||||||
@ -490,6 +501,25 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn hdmi_camera_profile_honors_installed_1080p_override() {
|
||||||
|
with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || {
|
||||||
|
with_var("LESAVKA_CAM_WIDTH", Some("1920"), || {
|
||||||
|
with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || {
|
||||||
|
with_var("LESAVKA_CAM_FPS", Some("30"), || {
|
||||||
|
let cfg = update_camera_config();
|
||||||
|
assert_eq!(cfg.output, CameraOutput::Hdmi);
|
||||||
|
assert_eq!(cfg.codec, CameraCodec::H264);
|
||||||
|
assert_eq!(cfg.width, 1920);
|
||||||
|
assert_eq!(cfg.height, 1080);
|
||||||
|
assert_eq!(cfg.fps, 30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() {
|
fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -3,6 +3,8 @@ use gstreamer as gst;
|
|||||||
use gstreamer::prelude::*;
|
use gstreamer::prelude::*;
|
||||||
use gstreamer_app as gst_app;
|
use gstreamer_app as gst_app;
|
||||||
use lesavka_common::lesavka::VideoPacket;
|
use lesavka_common::lesavka::VideoPacket;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::atomic::AtomicU64;
|
use std::sync::atomic::AtomicU64;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
@ -423,12 +425,20 @@ fn build_hdmi_sink(_cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
|
|||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
|
fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
|
||||||
if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") {
|
if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") {
|
||||||
return gst::ElementFactory::make(&name)
|
let normalized = name.trim().to_ascii_lowercase();
|
||||||
|
if normalized == "fbdev" || normalized == "fbdevsink" {
|
||||||
|
return build_fbdev_hdmi_sink();
|
||||||
|
}
|
||||||
|
|
||||||
|
let sink = gst::ElementFactory::make(&name)
|
||||||
.build()
|
.build()
|
||||||
.context("building HDMI sink");
|
.context("building HDMI sink")?;
|
||||||
|
disable_sink_clock_sync(&sink);
|
||||||
|
return Ok(sink);
|
||||||
}
|
}
|
||||||
|
|
||||||
if gst::ElementFactory::find("kmssink").is_some() {
|
if gst::ElementFactory::find("kmssink").is_some() {
|
||||||
|
unblank_framebuffer(&hdmi_fbdev_device());
|
||||||
let sink = gst::ElementFactory::make("kmssink").build()?;
|
let sink = gst::ElementFactory::make("kmssink").build()?;
|
||||||
if sink.has_property("driver-name", None) {
|
if sink.has_property("driver-name", None) {
|
||||||
let driver = std::env::var("LESAVKA_HDMI_DRIVER").unwrap_or_else(|_| "vc4".to_string());
|
let driver = std::env::var("LESAVKA_HDMI_DRIVER").unwrap_or_else(|_| "vc4".to_string());
|
||||||
@ -448,17 +458,98 @@ fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
|
|||||||
if sink.has_property("force-modesetting", None) {
|
if sink.has_property("force-modesetting", None) {
|
||||||
sink.set_property("force-modesetting", &true);
|
sink.set_property("force-modesetting", &true);
|
||||||
}
|
}
|
||||||
sink.set_property("sync", &false);
|
if sink.has_property("restore-crtc", None) {
|
||||||
|
let restore = read_bool_env("LESAVKA_HDMI_RESTORE_CRTC").unwrap_or(false);
|
||||||
|
sink.set_property("restore-crtc", &restore);
|
||||||
|
}
|
||||||
|
if sink.has_property("skip-vsync", None) {
|
||||||
|
let skip = read_bool_env("LESAVKA_HDMI_SKIP_VSYNC").unwrap_or(false);
|
||||||
|
sink.set_property("skip-vsync", &skip);
|
||||||
|
}
|
||||||
|
disable_sink_clock_sync(&sink);
|
||||||
return Ok(sink);
|
return Ok(sink);
|
||||||
}
|
}
|
||||||
|
|
||||||
let sink = gst::ElementFactory::make("autovideosink")
|
let sink = gst::ElementFactory::make("autovideosink")
|
||||||
.build()
|
.build()
|
||||||
.context("building HDMI sink")?;
|
.context("building HDMI sink")?;
|
||||||
let _ = sink.set_property("sync", &false);
|
disable_sink_clock_sync(&sink);
|
||||||
Ok(sink)
|
Ok(sink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn build_fbdev_hdmi_sink() -> anyhow::Result<gst::Element> {
|
||||||
|
let device = hdmi_fbdev_device();
|
||||||
|
unblank_framebuffer(&device);
|
||||||
|
|
||||||
|
let sink = gst::ElementFactory::make("fbdevsink")
|
||||||
|
.property("device", &device)
|
||||||
|
.build()
|
||||||
|
.context("building framebuffer HDMI sink")?;
|
||||||
|
disable_sink_clock_sync(&sink);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "lesavka_server::video",
|
||||||
|
%device,
|
||||||
|
"📺 HDMI sink using framebuffer scanout"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn hdmi_fbdev_device() -> String {
|
||||||
|
std::env::var("LESAVKA_HDMI_FBDEV")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "/dev/fb0".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn unblank_framebuffer(device: &str) {
|
||||||
|
let Some(name) = Path::new(device)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !name.starts_with("fb") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let blank_path = format!("/sys/class/graphics/{name}/blank");
|
||||||
|
match fs::write(&blank_path, b"0\n") {
|
||||||
|
Ok(()) => tracing::debug!(
|
||||||
|
target: "lesavka_server::video",
|
||||||
|
%device,
|
||||||
|
%blank_path,
|
||||||
|
"📺 HDMI framebuffer unblanked"
|
||||||
|
),
|
||||||
|
Err(error) => tracing::debug!(
|
||||||
|
target: "lesavka_server::video",
|
||||||
|
%device,
|
||||||
|
%blank_path,
|
||||||
|
%error,
|
||||||
|
"📺 HDMI framebuffer unblank skipped"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable_sink_clock_sync(sink: &gst::Element) {
|
||||||
|
if sink.has_property("sync", None) {
|
||||||
|
sink.set_property("sync", &false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bool_env(name: &str) -> Option<bool> {
|
||||||
|
let value = std::env::var(name).ok()?;
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"1" | "true" | "yes" | "on" => Some(true),
|
||||||
|
"0" | "false" | "no" | "off" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum CameraSink {
|
enum CameraSink {
|
||||||
Uvc(WebcamSink),
|
Uvc(WebcamSink),
|
||||||
Hdmi(HdmiSink),
|
Hdmi(HdmiSink),
|
||||||
|
|||||||
@ -66,6 +66,14 @@ fn main() {
|
|||||||
.join("client/src/output/video.rs")
|
.join("client/src/output/video.rs")
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.expect("canonical client output video path");
|
.expect("canonical client output video path");
|
||||||
|
let client_video_support = workspace_dir
|
||||||
|
.join("client/src/video_support.rs")
|
||||||
|
.canonicalize()
|
||||||
|
.expect("canonical client video_support path");
|
||||||
|
let client_relayctl = workspace_dir
|
||||||
|
.join("client/src/bin/lesavka-relayctl.rs")
|
||||||
|
.canonicalize()
|
||||||
|
.expect("canonical client relayctl bin path");
|
||||||
let common_cli = workspace_dir
|
let common_cli = workspace_dir
|
||||||
.join("common/src/bin/cli.rs")
|
.join("common/src/bin/cli.rs")
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
@ -131,6 +139,14 @@ fn main() {
|
|||||||
"cargo:rustc-env=LESAVKA_CLIENT_OUTPUT_VIDEO_SRC={}",
|
"cargo:rustc-env=LESAVKA_CLIENT_OUTPUT_VIDEO_SRC={}",
|
||||||
client_output_video.display()
|
client_output_video.display()
|
||||||
);
|
);
|
||||||
|
println!(
|
||||||
|
"cargo:rustc-env=LESAVKA_CLIENT_VIDEO_SUPPORT_SRC={}",
|
||||||
|
client_video_support.display()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"cargo:rustc-env=LESAVKA_CLIENT_RELAYCTL_BIN_SRC={}",
|
||||||
|
client_relayctl.display()
|
||||||
|
);
|
||||||
println!(
|
println!(
|
||||||
"cargo:rustc-env=LESAVKA_COMMON_CLI_BIN_SRC={}",
|
"cargo:rustc-env=LESAVKA_COMMON_CLI_BIN_SRC={}",
|
||||||
common_cli.display()
|
common_cli.display()
|
||||||
|
|||||||
@ -228,6 +228,8 @@ mod tests {
|
|||||||
use temp_env::with_var;
|
use temp_env::with_var;
|
||||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||||
|
|
||||||
|
const APP_SRC: &str = include_str!("../../client/src/app.rs");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn run_headless_reaches_pending_reactor_branch() {
|
fn run_headless_reaches_pending_reactor_branch() {
|
||||||
@ -372,4 +374,12 @@ mod tests {
|
|||||||
"stale mouse packets should be dropped locally"
|
"stale mouse packets should be dropped locally"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_loop_backoff_contract_protects_server_from_reconnect_storms() {
|
||||||
|
assert!(APP_SRC.contains("let mut delay = Duration::from_secs(1);"));
|
||||||
|
assert!(APP_SRC.contains("tokio::time::sleep(delay).await;"));
|
||||||
|
assert!(APP_SRC.contains("delay = app_support::next_delay(delay);"));
|
||||||
|
assert!(APP_SRC.contains("consecutive_source_failures = 0;"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,15 +30,15 @@ fn source_index(needle: &str) -> usize {
|
|||||||
#[test]
|
#[test]
|
||||||
fn launcher_default_size_stays_inside_1080p() {
|
fn launcher_default_size_stays_inside_1080p() {
|
||||||
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
|
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
|
||||||
assert_eq!(const_i32("LAUNCHER_DEFAULT_HEIGHT"), 820);
|
assert_eq!(const_i32("LAUNCHER_DEFAULT_HEIGHT"), 940);
|
||||||
assert!(const_i32("LAUNCHER_DEFAULT_WIDTH") <= 1920);
|
assert!(const_i32("LAUNCHER_DEFAULT_WIDTH") <= 1920);
|
||||||
assert!(const_i32("LAUNCHER_DEFAULT_HEIGHT") <= 1080);
|
assert!(const_i32("LAUNCHER_DEFAULT_HEIGHT") <= 1080);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eye_panes_keep_the_locked_larger_preview_footprint() {
|
fn eye_panes_keep_the_locked_larger_preview_footprint() {
|
||||||
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 460);
|
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 568);
|
||||||
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 258);
|
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 320);
|
||||||
assert!(
|
assert!(
|
||||||
UI_SRC.contains("caption_label.set_halign(gtk::Align::End)")
|
UI_SRC.contains("caption_label.set_halign(gtk::Align::End)")
|
||||||
|| UI_SRC.contains("capture_label.set_halign(gtk::Align::End)")
|
|| UI_SRC.contains("capture_label.set_halign(gtk::Align::End)")
|
||||||
@ -117,13 +117,42 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
fn media_controls_own_stream_toggles_and_inline_gain_controls() {
|
||||||
assert!(!UI_SRC.contains("Remote Audio"));
|
assert!(!UI_SRC.contains("Remote Audio"));
|
||||||
assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);"));
|
assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);"));
|
||||||
assert!(UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));"));
|
assert!(!UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));"));
|
||||||
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Webcam\")"));
|
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Camera\")"));
|
||||||
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Mic\")"));
|
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Mic\")"));
|
||||||
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Audio\")"));
|
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Speaker\")"));
|
||||||
|
assert!(UI_SRC.contains("let camera_quality_combo = gtk::ComboBoxText::new();"));
|
||||||
|
assert!(UI_SRC.contains("sync_camera_quality_combo("));
|
||||||
|
assert!(UI_SRC.contains("camera_quality_combo.set_size_request(88, -1);"));
|
||||||
|
assert!(
|
||||||
|
UI_SRC.contains("let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);")
|
||||||
|
);
|
||||||
|
assert!(UI_SRC.contains("camera_selectors.append(&camera_combo);"));
|
||||||
|
assert!(UI_SRC.contains("camera_selectors.append(&camera_quality_combo);"));
|
||||||
|
assert!(
|
||||||
|
source_index("camera_selectors.append(&camera_combo);")
|
||||||
|
< source_index("camera_selectors.append(&camera_quality_combo);")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
UI_SRC.contains("let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);")
|
||||||
|
);
|
||||||
|
assert!(UI_SRC.contains("speaker_selectors.append(&speaker_combo);"));
|
||||||
|
assert!(UI_SRC.contains("speaker_selectors.append(&audio_gain_scale);"));
|
||||||
|
assert!(
|
||||||
|
UI_SRC
|
||||||
|
.contains("let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);")
|
||||||
|
);
|
||||||
|
assert!(UI_SRC.contains("microphone_selectors.append(µphone_combo);"));
|
||||||
|
assert!(UI_SRC.contains("microphone_selectors.append(&mic_gain_scale);"));
|
||||||
|
assert_eq!(
|
||||||
|
UI_SRC
|
||||||
|
.matches("attach_device_control_row(\n &media_grid")
|
||||||
|
.count(),
|
||||||
|
3
|
||||||
|
);
|
||||||
assert!(!UI_SRC.contains("camera_combo.append(Some(\"auto\")"));
|
assert!(!UI_SRC.contains("camera_combo.append(Some(\"auto\")"));
|
||||||
assert!(!UI_SRC.contains("speaker_combo.append(Some(\"auto\")"));
|
assert!(!UI_SRC.contains("speaker_combo.append(Some(\"auto\")"));
|
||||||
assert!(!UI_SRC.contains("microphone_combo.append(Some(\"auto\")"));
|
assert!(!UI_SRC.contains("microphone_combo.append(Some(\"auto\")"));
|
||||||
@ -132,14 +161,14 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
|||||||
assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);"));
|
assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);"));
|
||||||
assert!(UI_SRC.contains("let audio_gain_scale ="));
|
assert!(UI_SRC.contains("let audio_gain_scale ="));
|
||||||
assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);"));
|
assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);"));
|
||||||
assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);"));
|
assert!(UI_SRC.contains("audio_gain_scale.set_size_request(96, -1);"));
|
||||||
assert!(UI_SRC.contains("let mic_gain_scale ="));
|
assert!(UI_SRC.contains("let mic_gain_scale ="));
|
||||||
assert!(UI_SRC.contains("mic_gain_scale.set_draw_value(false);"));
|
assert!(UI_SRC.contains("mic_gain_scale.set_draw_value(false);"));
|
||||||
assert!(UI_SRC.contains("mic_gain_value.set_width_chars(5);"));
|
assert!(UI_SRC.contains("mic_gain_scale.set_size_request(96, -1);"));
|
||||||
assert!(UI_SRC.contains("audio_gain_row.set_size_request(220, -1);"));
|
assert!(!UI_SRC.contains("audio_gain_row.set_size_request(220, -1);"));
|
||||||
assert!(UI_SRC.contains("mic_gain_row.set_size_request(220, -1);"));
|
assert!(!UI_SRC.contains("mic_gain_row.set_size_request(220, -1);"));
|
||||||
assert!(UI_SRC.contains("power_shell.append(&audio_gain_row);"));
|
assert!(!UI_SRC.contains("power_shell.append(&audio_gain_row);"));
|
||||||
assert!(UI_SRC.contains("power_shell.append(&mic_gain_row);"));
|
assert!(!UI_SRC.contains("power_shell.append(&mic_gain_row);"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
UI_SRC
|
UI_SRC
|
||||||
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
||||||
@ -149,14 +178,10 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")
|
source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")
|
||||||
< source_index("let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
|
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
source_index("power_shell.append(&audio_gain_row);")
|
source_index("power_shell.append(&power_row);")
|
||||||
< source_index("power_shell.append(&mic_gain_row);")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
source_index("power_shell.append(&mic_gain_row);")
|
|
||||||
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
||||||
);
|
);
|
||||||
assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);"));
|
assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);"));
|
||||||
|
|||||||
@ -38,6 +38,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
|
|||||||
".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);"
|
".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
assert!(UI_RUNTIME_SRC.contains(".camera_quality_combo"));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("widgets.camera_quality_combo.set_sensitive("));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("state.devices.camera.is_some()"));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains("state.camera_quality.is_some()"));
|
||||||
assert!(UI_RUNTIME_SRC.contains(
|
assert!(UI_RUNTIME_SRC.contains(
|
||||||
".microphone_combo\n .set_sensitive(!relay_live && state.channels.microphone);"
|
".microphone_combo\n .set_sensitive(!relay_live && state.channels.microphone);"
|
||||||
));
|
));
|
||||||
@ -46,6 +50,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
|
|||||||
".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);"
|
".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
assert!(UI_RUNTIME_SRC.contains(".audio_gain_scale"));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.audio);"));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains(".mic_gain_scale"));
|
||||||
|
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.microphone);"));
|
||||||
assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);"));
|
assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);"));
|
||||||
assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);"));
|
assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);"));
|
||||||
assert!(UI_RUNTIME_SRC.contains("widgets.camera_channel_toggle.set_sensitive(!relay_live);"));
|
assert!(UI_RUNTIME_SRC.contains("widgets.camera_channel_toggle.set_sensitive(!relay_live);"));
|
||||||
@ -98,3 +106,16 @@ fn active_relay_keeps_local_upstream_camera_and_microphone_evidence_visible() {
|
|||||||
assert!(MICROPHONE_SRC.contains("appsink name=level_sink"));
|
assert!(MICROPHONE_SRC.contains("appsink name=level_sink"));
|
||||||
assert!(MICROPHONE_SRC.contains("spawn_mic_level_tap"));
|
assert!(MICROPHONE_SRC.contains("spawn_mic_level_tap"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() {
|
||||||
|
assert!(UI_SRC.contains("selected_camera_quality(&camera_quality_combo"));
|
||||||
|
assert!(UI_SRC.contains("sync_camera_quality_selection"));
|
||||||
|
assert!(UI_SRC.contains("tests.set_camera_quality"));
|
||||||
|
assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality"));
|
||||||
|
assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)"));
|
||||||
|
assert!(DEVICE_TEST_SRC.contains("capsfilter caps=\\\"video/x-raw"));
|
||||||
|
assert!(CAMERA_SRC.contains("env_u32(\"LESAVKA_CAM_WIDTH\", cfg.map_or"));
|
||||||
|
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\""));
|
||||||
|
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\""));
|
||||||
|
}
|
||||||
|
|||||||
@ -231,4 +231,49 @@ exit 0
|
|||||||
fs::write(&path, "bad nonce\n").expect("write invalid gain");
|
fs::write(&path, "bad nonce\n").expect("write invalid gain");
|
||||||
assert_eq!(read_audio_gain_control(&path), None);
|
assert_eq!(read_audio_gain_control(&path), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_gain_parsing_and_formatting_are_stable() {
|
||||||
|
assert_eq!(parse_audio_gain("3.5 ignored"), Some(3.5));
|
||||||
|
assert_eq!(parse_audio_gain("99"), Some(MAX_AUDIO_GAIN));
|
||||||
|
assert_eq!(parse_audio_gain("-1"), Some(0.0));
|
||||||
|
assert_eq!(parse_audio_gain("nan"), None);
|
||||||
|
assert_eq!(parse_audio_gain(""), None);
|
||||||
|
assert_eq!(format_audio_gain_for_gst(2.1256), "2.126");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sink_override_escaping_and_pactl_ranking_are_stable() {
|
||||||
|
assert_eq!(normalize_sink_override("autoaudiosink"), "autoaudiosink");
|
||||||
|
assert_eq!(
|
||||||
|
normalize_sink_override("fakesink sync=false"),
|
||||||
|
"fakesink sync=false"
|
||||||
|
);
|
||||||
|
let normal = normalize_sink_override("alsa_output.pci");
|
||||||
|
assert!(normal.contains("pulsesink device=\"alsa_output.pci\""));
|
||||||
|
assert!(normal.contains("buffer-time=350000"));
|
||||||
|
let bluetooth = pulsesink_device_element("bluez_output.headset");
|
||||||
|
assert!(bluetooth.contains("buffer-time=750000"));
|
||||||
|
let escaped = pulsesink_device_element("sink\\\"name");
|
||||||
|
assert!(escaped.contains("sink\\\\\\\"name"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_pactl_default_sink("Server: x\nDefault Sink: my.default \n"),
|
||||||
|
Some("my.default".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(parse_pactl_default_sink("Default Sink: \n"), None);
|
||||||
|
let sinks = parse_pactl_short_sinks(
|
||||||
|
"bad\n1 idle.sink module IDLE\n2 run.sink module RUNNING\n3 suspended.sink module SUSPENDED\n",
|
||||||
|
Some("missing.default"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sinks[0],
|
||||||
|
("missing.default".to_string(), "DEFAULT".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(sinks[1], ("run.sink".to_string(), "RUNNING".to_string()));
|
||||||
|
assert_eq!(sink_state_rank("RUNNING"), 0);
|
||||||
|
assert_eq!(sink_state_rank("IDLE"), 1);
|
||||||
|
assert_eq!(sink_state_rank("SUSPENDED"), 2);
|
||||||
|
assert_eq!(sink_state_rank("UNKNOWN"), 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
testing/tests/client_relayctl_binary_contract.rs
Normal file
49
testing/tests/client_relayctl_binary_contract.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//! Include-based coverage for relay control CLI parsing and helpers.
|
||||||
|
//!
|
||||||
|
//! Scope: include `client/src/bin/lesavka-relayctl.rs` and exercise helper
|
||||||
|
//! branches that do not require a live relay server.
|
||||||
|
//! Targets: `client/src/bin/lesavka-relayctl.rs`.
|
||||||
|
//! Why: relay power recovery controls need parser coverage without depending on
|
||||||
|
//! a live relay endpoint.
|
||||||
|
|
||||||
|
#[allow(warnings)]
|
||||||
|
mod relayctl_binary {
|
||||||
|
include!(env!("LESAVKA_CLIENT_RELAYCTL_BIN_SRC"));
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_aliases_and_usage_are_stable() {
|
||||||
|
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
|
||||||
|
assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status));
|
||||||
|
assert_eq!(CommandKind::parse("auto"), Some(CommandKind::Auto));
|
||||||
|
assert_eq!(CommandKind::parse("on"), Some(CommandKind::On));
|
||||||
|
assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On));
|
||||||
|
assert_eq!(CommandKind::parse("off"), Some(CommandKind::Off));
|
||||||
|
assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off));
|
||||||
|
assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb));
|
||||||
|
assert_eq!(
|
||||||
|
CommandKind::parse("recover-usb"),
|
||||||
|
Some(CommandKind::ResetUsb)
|
||||||
|
);
|
||||||
|
assert_eq!(CommandKind::parse("bad"), None);
|
||||||
|
assert!(usage().contains("lesavka-relayctl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn connect_rejects_invalid_endpoint_without_network_retry() {
|
||||||
|
let err = connect("not a uri").await.expect_err("invalid endpoint");
|
||||||
|
assert!(err.to_string().contains("invalid relay server address"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn print_state_is_non_panicking_for_populated_payload() {
|
||||||
|
print_state(lesavka_common::lesavka::CapturePowerState {
|
||||||
|
available: true,
|
||||||
|
enabled: true,
|
||||||
|
mode: "manual".to_string(),
|
||||||
|
detected_devices: 2,
|
||||||
|
active_leases: 1,
|
||||||
|
unit: "lesavka-capture.service".to_string(),
|
||||||
|
detail: "ok".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
65
testing/tests/client_relayctl_process_contract.rs
Normal file
65
testing/tests/client_relayctl_process_contract.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
//! Process-level coverage for relay control CLI argument handling.
|
||||||
|
//!
|
||||||
|
//! Scope: launch the real `lesavka-relayctl` binary for argument-only paths.
|
||||||
|
//! Targets: `client/src/bin/lesavka-relayctl.rs`.
|
||||||
|
//! Why: CLI-only failures should stay fast and local instead of retrying a bad
|
||||||
|
//! network endpoint.
|
||||||
|
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn candidate_dirs() -> Vec<PathBuf> {
|
||||||
|
let exe = std::env::current_exe().expect("current exe path");
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
if let Some(parent) = exe.parent() {
|
||||||
|
dirs.push(parent.to_path_buf());
|
||||||
|
if let Some(grand) = parent.parent() {
|
||||||
|
dirs.push(grand.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs.push(PathBuf::from("target/debug"));
|
||||||
|
dirs.push(PathBuf::from("target/llvm-cov-target/debug"));
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_binary(name: &str) -> Option<PathBuf> {
|
||||||
|
candidate_dirs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|dir| dir.join(name))
|
||||||
|
.find(|path| path.exists() && path.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn relayctl_help_exits_successfully() {
|
||||||
|
let Some(bin) = find_binary("lesavka-relayctl") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = Command::new(Path::new(&bin))
|
||||||
|
.arg("--help")
|
||||||
|
.output()
|
||||||
|
.expect("spawn lesavka-relayctl --help");
|
||||||
|
|
||||||
|
assert!(output.status.success());
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("lesavka-relayctl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn relayctl_rejects_bad_arguments_before_network_use() {
|
||||||
|
let Some(bin) = find_binary("lesavka-relayctl") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = Command::new(Path::new(&bin))
|
||||||
|
.arg("nonsense")
|
||||||
|
.output()
|
||||||
|
.expect("spawn lesavka-relayctl bad command");
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(stderr.contains("unknown command"));
|
||||||
|
}
|
||||||
31
testing/tests/client_video_support_include_contract.rs
Normal file
31
testing/tests/client_video_support_include_contract.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
//! Module-path coverage for client-side H.264 decoder selection.
|
||||||
|
//!
|
||||||
|
//! Scope: include the client decoder selection helper directly.
|
||||||
|
//! Targets: `client/src/video_support.rs`.
|
||||||
|
//! Why: operator decoder overrides should fall back cleanly on machines with
|
||||||
|
//! different GStreamer plugin sets.
|
||||||
|
|
||||||
|
#[path = "../../client/src/video_support.rs"]
|
||||||
|
mod video_support;
|
||||||
|
|
||||||
|
use serial_test::serial;
|
||||||
|
use temp_env::with_var;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn decoder_override_accepts_decodebin_without_factory_lookup() {
|
||||||
|
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
|
||||||
|
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn decoder_override_ignores_blank_or_unknown_values() {
|
||||||
|
with_var("LESAVKA_H264_DECODER", Some(" "), || {
|
||||||
|
assert!(!video_support::pick_h264_decoder().trim().is_empty());
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_H264_DECODER", Some("not-a-real-decoder"), || {
|
||||||
|
assert!(!video_support::pick_h264_decoder().trim().is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -11,12 +11,47 @@
|
|||||||
mod server_audio_contract;
|
mod server_audio_contract;
|
||||||
|
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::server_audio_contract::{ClipTap, Voice, ear};
|
use super::server_audio_contract::{ClipTap, Voice, ear, start_pipeline_or_reset};
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use gstreamer as gst;
|
||||||
|
use gstreamer::prelude::*;
|
||||||
use lesavka_common::lesavka::AudioPacket;
|
use lesavka_common::lesavka::AudioPacket;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
const AUDIO_SRC: &str = include_str!("../../server/src/audio.rs");
|
||||||
|
|
||||||
|
fn source_index(needle: &str) -> usize {
|
||||||
|
AUDIO_SRC
|
||||||
|
.find(needle)
|
||||||
|
.unwrap_or_else(|| panic!("missing source marker: {needle}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_pipeline_start_contract_resets_and_does_not_spawn_bus_first() {
|
||||||
|
assert!(AUDIO_SRC.contains("let _ = pipeline.set_state(gst::State::Null);"));
|
||||||
|
assert!(AUDIO_SRC.contains("impl Drop for Voice"));
|
||||||
|
assert!(
|
||||||
|
source_index("start_pipeline_or_reset(&pipeline, \"starting audio pipeline\")?")
|
||||||
|
< source_index("spawn_pipeline_bus_logger(bus, \"audio\"")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source_index("start_pipeline_or_reset(&pipeline, \"starting voice pipeline\")?")
|
||||||
|
< source_index("spawn_pipeline_bus_logger(bus, \"voice\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn start_pipeline_or_reset_starts_empty_pipeline() {
|
||||||
|
let _ = gst::init();
|
||||||
|
let pipeline = gst::Pipeline::new();
|
||||||
|
start_pipeline_or_reset(&pipeline, "starting contract pipeline")
|
||||||
|
.expect("empty pipeline should enter playing");
|
||||||
|
assert_eq!(pipeline.current_state(), gst::State::Playing);
|
||||||
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn ear_rejects_malformed_pipeline_device_string() {
|
fn ear_rejects_malformed_pipeline_device_string() {
|
||||||
|
|||||||
@ -93,6 +93,25 @@ fn camera_config_forced_hdmi_tracks_cached_state() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn camera_config_forced_hdmi_honors_1080p_uplink_override() {
|
||||||
|
with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || {
|
||||||
|
with_var("LESAVKA_CAM_WIDTH", Some("1920"), || {
|
||||||
|
with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || {
|
||||||
|
with_var("LESAVKA_CAM_FPS", Some("30"), || {
|
||||||
|
let cfg = update_camera_config();
|
||||||
|
assert_eq!(cfg.output, CameraOutput::Hdmi);
|
||||||
|
assert_eq!(cfg.codec, CameraCodec::H264);
|
||||||
|
assert_eq!(cfg.width, 1920);
|
||||||
|
assert_eq!(cfg.height, 1080);
|
||||||
|
assert_eq!(cfg.fps, 30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn camera_config_output_override_is_case_insensitive() {
|
fn camera_config_output_override_is_case_insensitive() {
|
||||||
|
|||||||
33
testing/tests/server_install_script_contract.rs
Normal file
33
testing/tests/server_install_script_contract.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
//! Contract tests for server install-time operational defaults.
|
||||||
|
//!
|
||||||
|
//! Scope: statically guard the generated `/etc/lesavka/server.env` values.
|
||||||
|
//! Targets: `scripts/install/server.sh`.
|
||||||
|
//! Why: HDMI capture adapter settings should be reproducible after reboot or
|
||||||
|
//! reinstall instead of living as one-off shell state.
|
||||||
|
|
||||||
|
const SERVER_INSTALL: &str = include_str!("../../scripts/install/server.sh");
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_install_pins_hdmi_camera_and_display_defaults() {
|
||||||
|
for expected in [
|
||||||
|
"LESAVKA_CAM_OUTPUT=%s",
|
||||||
|
"LESAVKA_CAM_WIDTH=%s",
|
||||||
|
"LESAVKA_CAM_HEIGHT=%s",
|
||||||
|
"LESAVKA_CAM_FPS=%s",
|
||||||
|
"LESAVKA_HDMI_WIDTH=%s",
|
||||||
|
"LESAVKA_HDMI_HEIGHT=%s",
|
||||||
|
"LESAVKA_HDMI_SINK=%s",
|
||||||
|
"LESAVKA_HDMI_FBDEV=%s",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
SERVER_INSTALL.contains(expected),
|
||||||
|
"install script should emit {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_WIDTH:-1920}"));
|
||||||
|
assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_HEIGHT:-1080}"));
|
||||||
|
assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_FPS:-30}"));
|
||||||
|
assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_WIDTH:-1920}"));
|
||||||
|
assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_HEIGHT:-1080}"));
|
||||||
|
assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_SINK:-fbdevsink}"));
|
||||||
|
}
|
||||||
@ -1,16 +1,26 @@
|
|||||||
//! Integration coverage for server process startup behavior.
|
//! Integration coverage for server process startup behavior.
|
||||||
//!
|
//!
|
||||||
//! Scope: launch the real `lesavka-server` binary and assert startup reaches a
|
//! Scope: launch the real `lesavka-server` binary and assert startup stays
|
||||||
//! terminal state quickly in this non-gadget test environment.
|
//! resilient in this non-gadget test environment.
|
||||||
//! Targets: `server/src/main.rs`.
|
//! Targets: `server/src/main.rs`.
|
||||||
//! Why: process-level boot behavior should remain deterministic when required
|
//! Why: missing gadget endpoints should not crash the relay; the server keeps
|
||||||
//! gadget endpoints are unavailable.
|
//! running and opens HID lazily when the device nodes appear.
|
||||||
|
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
fn cargo_binary(name: &str) -> Option<PathBuf> {
|
||||||
|
let key = format!("CARGO_BIN_EXE_{name}");
|
||||||
|
option_env!("CARGO_BIN_EXE_lesavka-server")
|
||||||
|
.filter(|_| name == "lesavka-server")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| std::env::var_os(key).map(PathBuf::from))
|
||||||
|
.filter(|path| path.exists() && path.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
fn candidate_dirs() -> Vec<PathBuf> {
|
fn candidate_dirs() -> Vec<PathBuf> {
|
||||||
let exe = std::env::current_exe().expect("current exe path");
|
let exe = std::env::current_exe().expect("current exe path");
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
@ -26,18 +36,51 @@ fn candidate_dirs() -> Vec<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn find_binary(name: &str) -> Option<PathBuf> {
|
fn find_binary(name: &str) -> Option<PathBuf> {
|
||||||
candidate_dirs()
|
cargo_binary(name).or_else(|| {
|
||||||
.into_iter()
|
candidate_dirs()
|
||||||
.map(|dir| dir.join(name))
|
.into_iter()
|
||||||
.find(|path| path.exists() && path.is_file())
|
.map(|dir| dir.join(name))
|
||||||
|
.find(|path| path.exists() && path.is_file())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_package_version() -> Option<String> {
|
||||||
|
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()?
|
||||||
|
.join("server/Cargo.toml");
|
||||||
|
fs::read_to_string(manifest).ok()?.lines().find_map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
trimmed
|
||||||
|
.strip_prefix("version")
|
||||||
|
.and_then(|value| value.split('=').nth(1))
|
||||||
|
.map(str::trim)
|
||||||
|
.and_then(|value| value.strip_prefix('"')?.strip_suffix('"'))
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_log(path: &PathBuf, needle: &str, deadline: Instant) -> String {
|
||||||
|
loop {
|
||||||
|
let log = fs::read_to_string(path).unwrap_or_default();
|
||||||
|
if log.contains(needle) {
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"timed out waiting for log line {needle:?}; log was:\n{log}"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn server_binary_exits_quickly_without_hid_nodes() {
|
fn server_binary_stays_up_with_missing_hid_nodes_and_current_version() {
|
||||||
let Some(bin) = find_binary("lesavka-server") else {
|
let Some(bin) = find_binary("lesavka-server") else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let log_path = PathBuf::from("/tmp/lesavka-server.log");
|
||||||
|
let _ = fs::remove_file(&log_path);
|
||||||
|
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.env("LESAVKA_DISABLE_UVC", "1")
|
.env("LESAVKA_DISABLE_UVC", "1")
|
||||||
@ -45,18 +88,22 @@ fn server_binary_exits_quickly_without_hid_nodes() {
|
|||||||
.expect("spawn lesavka-server");
|
.expect("spawn lesavka-server");
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(3);
|
let deadline = Instant::now() + Duration::from_secs(3);
|
||||||
loop {
|
if let Some(version) = server_package_version() {
|
||||||
if let Some(status) = child.try_wait().expect("poll child") {
|
let _ = wait_for_log(
|
||||||
assert!(
|
&log_path,
|
||||||
!status.success(),
|
&format!("lesavka_server v{version} starting up"),
|
||||||
"server unexpectedly succeeded in test environment"
|
deadline,
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
}
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
let _ = child.kill();
|
|
||||||
panic!("server did not terminate within startup timeout");
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
|
||||||
}
|
}
|
||||||
|
let log = wait_for_log(
|
||||||
|
&log_path,
|
||||||
|
"HID endpoints are not ready; relay will keep running and open them lazily",
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
child.try_wait().expect("poll child").is_none(),
|
||||||
|
"server should stay alive with lazy HID recovery; log was:\n{log}"
|
||||||
|
);
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,6 +70,33 @@ mod video_sinks_include_contract {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn build_hdmi_sink_configures_fbdev_override_for_capture_adapters() {
|
||||||
|
init_gst();
|
||||||
|
if gst::ElementFactory::find("fbdevsink").is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
with_var("LESAVKA_HDMI_SINK", Some("fbdevsink"), || {
|
||||||
|
with_var("LESAVKA_HDMI_FBDEV", Some("/dev/fb42"), || {
|
||||||
|
let sink = build_hdmi_sink(&cfg(CameraCodec::H264))
|
||||||
|
.expect("fbdevsink override should build");
|
||||||
|
|
||||||
|
if sink.has_property("device", None) {
|
||||||
|
assert_eq!(sink.property::<String>("device"), "/dev/fb42");
|
||||||
|
}
|
||||||
|
if sink.has_property("sync", None) {
|
||||||
|
assert!(
|
||||||
|
!sink.property::<bool>("sync"),
|
||||||
|
"fbdev HDMI output should not clock-sync WAN camera frames"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn build_hdmi_sink_invalid_override_surfaces_error() {
|
fn build_hdmi_sink_invalid_override_surfaces_error() {
|
||||||
@ -112,25 +139,47 @@ mod video_sinks_include_contract {
|
|||||||
|
|
||||||
with_var("LESAVKA_HDMI_SINK", None::<&str>, || {
|
with_var("LESAVKA_HDMI_SINK", None::<&str>, || {
|
||||||
with_var("LESAVKA_HDMI_DRIVER", Some("vc4"), || {
|
with_var("LESAVKA_HDMI_DRIVER", Some("vc4"), || {
|
||||||
let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264))
|
with_var("LESAVKA_HDMI_RESTORE_CRTC", None::<&str>, || {
|
||||||
.expect("kmssink should build");
|
let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264))
|
||||||
|
.expect("kmssink should build");
|
||||||
|
|
||||||
if sink.has_property("force-modesetting", None) {
|
if sink.has_property("force-modesetting", None) {
|
||||||
assert!(
|
assert!(
|
||||||
sink.property::<bool>("force-modesetting"),
|
sink.property::<bool>("force-modesetting"),
|
||||||
"kmssink must drive the HDMI mode instead of relying on desktop state"
|
"kmssink must drive the HDMI mode instead of relying on desktop state"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if sink.has_property("connector-id", None) {
|
if sink.has_property("restore-crtc", None) {
|
||||||
assert_eq!(sink.property::<i32>("connector-id"), 43);
|
assert!(
|
||||||
}
|
!sink.property::<bool>("restore-crtc"),
|
||||||
if sink.has_property("driver-name", None) {
|
"dedicated HDMI capture output should not restore the console CRTC"
|
||||||
assert_eq!(sink.property::<String>("driver-name"), "vc4");
|
);
|
||||||
}
|
}
|
||||||
|
if sink.has_property("connector-id", None) {
|
||||||
|
assert_eq!(sink.property::<i32>("connector-id"), 43);
|
||||||
|
}
|
||||||
|
if sink.has_property("driver-name", None) {
|
||||||
|
assert_eq!(sink.property::<String>("driver-name"), "vc4");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn bool_env_parser_accepts_operator_friendly_values() {
|
||||||
|
with_var("LESAVKA_BOOL_TEST", Some("yes"), || {
|
||||||
|
assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(true));
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_BOOL_TEST", Some("off"), || {
|
||||||
|
assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(false));
|
||||||
|
});
|
||||||
|
with_var("LESAVKA_BOOL_TEST", Some("shrug"), || {
|
||||||
|
assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn camera_sink_dispatch_is_stable_for_hdmi_variant() {
|
fn camera_sink_dispatch_is_stable_for_hdmi_variant() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user