661 lines
22 KiB
Rust
661 lines
22 KiB
Rust
use anyhow::Result;
|
|
use gtk::{glib, prelude::*};
|
|
use std::{
|
|
cell::RefCell,
|
|
path::{Path, PathBuf},
|
|
process::{Child, Command},
|
|
rc::Rc,
|
|
};
|
|
|
|
use super::{
|
|
LAUNCHER_FOCUS_SIGNAL_ENV,
|
|
device_test::{DeviceTestController, DeviceTestKind},
|
|
launcher_focus_signal_path,
|
|
preview::LauncherPreview,
|
|
runtime_env_vars,
|
|
state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
|
|
ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle},
|
|
};
|
|
|
|
pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL";
|
|
pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE";
|
|
pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control";
|
|
pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state";
|
|
|
|
pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) {
|
|
widgets
|
|
.summary
|
|
.relay_value
|
|
.set_text(if child_running || state.remote_active {
|
|
"Running"
|
|
} else {
|
|
"Stopped"
|
|
});
|
|
widgets
|
|
.summary
|
|
.routing_value
|
|
.set_text(&capitalize(routing_name(state.routing)));
|
|
widgets
|
|
.summary
|
|
.power_value
|
|
.set_text(&capture_power_label(&state.capture_power));
|
|
widgets.summary.displays_value.set_text(&format!(
|
|
"L {} / R {}",
|
|
state.display_surface(0).label(),
|
|
state.display_surface(1).label()
|
|
));
|
|
widgets
|
|
.summary
|
|
.shortcut_value
|
|
.set_text(&selected_toggle_key_label(&widgets.toggle_key_combo));
|
|
|
|
widgets
|
|
.power_detail
|
|
.set_text(&capture_power_detail(&state.capture_power));
|
|
widgets
|
|
.launch_plan_title
|
|
.set_text(&launch_plan_title(state, child_running));
|
|
widgets
|
|
.launch_plan_summary
|
|
.set_text(&launch_plan_summary(state));
|
|
widgets
|
|
.launch_plan_detail
|
|
.set_text(&launch_plan_detail(state, child_running));
|
|
|
|
widgets.start_button.set_label(if child_running {
|
|
"Relay Running"
|
|
} else {
|
|
"Start Relay"
|
|
});
|
|
widgets.start_button.set_sensitive(!child_running);
|
|
widgets.stop_button.set_sensitive(child_running);
|
|
widgets.clipboard_button.set_sensitive(child_running);
|
|
widgets.probe_button.set_sensitive(true);
|
|
widgets.input_toggle_button.set_label(match state.routing {
|
|
InputRouting::Remote => "Route Inputs To Local",
|
|
InputRouting::Local => "Route Inputs To Remote",
|
|
});
|
|
let power_available = state.capture_power.available;
|
|
widgets
|
|
.power_auto_button
|
|
.set_sensitive(power_available && !matches!(state.capture_power.mode.as_str(), "auto"));
|
|
widgets.power_on_button.set_sensitive(
|
|
power_available && !matches!(state.capture_power.mode.as_str(), "forced-on"),
|
|
);
|
|
widgets.power_off_button.set_sensitive(
|
|
power_available && !matches!(state.capture_power.mode.as_str(), "forced-off"),
|
|
);
|
|
sync_power_mode_button_styles(widgets, state.capture_power.mode.as_str());
|
|
|
|
for monitor_id in 0..2 {
|
|
refresh_display_pane(
|
|
&widgets.display_panes[monitor_id],
|
|
state.display_surface(monitor_id),
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) {
|
|
let camera_running = tests.is_running(DeviceTestKind::Camera);
|
|
let microphone_running = tests.is_running(DeviceTestKind::Microphone);
|
|
let speaker_running = tests.is_running(DeviceTestKind::Speaker);
|
|
|
|
widgets.camera_test_button.set_label(if camera_running {
|
|
"Stop Preview"
|
|
} else {
|
|
"Start Preview"
|
|
});
|
|
widgets
|
|
.microphone_test_button
|
|
.set_label(if microphone_running {
|
|
"Stop Monitor"
|
|
} else {
|
|
"Monitor Mic"
|
|
});
|
|
widgets.speaker_test_button.set_label(if speaker_running {
|
|
"Stop Tone"
|
|
} else {
|
|
"Play Tone"
|
|
});
|
|
widgets.local_test_detail.set_text(&local_test_detail(
|
|
camera_running,
|
|
microphone_running,
|
|
speaker_running,
|
|
));
|
|
}
|
|
|
|
pub fn update_test_action_result(
|
|
widgets: &LauncherWidgets,
|
|
tests: &mut DeviceTestController,
|
|
result: Result<bool>,
|
|
start_msg: &str,
|
|
stop_msg: &str,
|
|
) {
|
|
match result {
|
|
Ok(true) => widgets.status_label.set_text(start_msg),
|
|
Ok(false) => widgets.status_label.set_text(stop_msg),
|
|
Err(err) => widgets
|
|
.status_label
|
|
.set_text(&format!("Device test failed: {err}")),
|
|
}
|
|
refresh_test_buttons(widgets, tests);
|
|
}
|
|
|
|
pub fn open_popout_window(
|
|
app: >k::Application,
|
|
preview: &LauncherPreview,
|
|
state: &Rc<RefCell<LauncherState>>,
|
|
child_proc: &Rc<RefCell<Option<Child>>>,
|
|
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
|
|
widgets: &LauncherWidgets,
|
|
monitor_id: usize,
|
|
) {
|
|
if popouts.borrow()[monitor_id].is_some() {
|
|
return;
|
|
}
|
|
|
|
if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() {
|
|
binding.set_enabled(false);
|
|
}
|
|
|
|
let window = gtk::ApplicationWindow::builder()
|
|
.application(app)
|
|
.title(format!(
|
|
"Lesavka {}",
|
|
widgets.display_panes[monitor_id].title
|
|
))
|
|
.default_width(1280)
|
|
.default_height(760)
|
|
.build();
|
|
super::ui_components::install_css(&window);
|
|
window.maximize();
|
|
|
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
|
root.set_margin_start(14);
|
|
root.set_margin_end(14);
|
|
root.set_margin_top(14);
|
|
root.set_margin_bottom(14);
|
|
|
|
let title = gtk::Label::new(Some(&widgets.display_panes[monitor_id].title));
|
|
title.add_css_class("title-3");
|
|
title.set_halign(gtk::Align::Center);
|
|
root.append(&title);
|
|
|
|
let picture = gtk::Picture::new();
|
|
picture.set_hexpand(true);
|
|
picture.set_vexpand(true);
|
|
picture.set_can_shrink(true);
|
|
root.append(&picture);
|
|
|
|
let stream_status = gtk::Label::new(Some("Waiting for stream..."));
|
|
stream_status.set_halign(gtk::Align::Start);
|
|
root.append(&stream_status);
|
|
|
|
let binding = preview
|
|
.install_on_picture(monitor_id, &picture, &stream_status)
|
|
.expect("preview binding for popout");
|
|
|
|
window.set_child(Some(&root));
|
|
|
|
let state_handle = Rc::clone(state);
|
|
let child_proc_handle = Rc::clone(child_proc);
|
|
let popouts_handle = Rc::clone(popouts);
|
|
let widgets_handle = widgets.clone();
|
|
let close_binding = binding.clone();
|
|
window.connect_close_request(move |_| {
|
|
let handle = {
|
|
let mut popouts = popouts_handle.borrow_mut();
|
|
popouts[monitor_id].take()
|
|
};
|
|
if let Some(handle) = handle {
|
|
handle.binding.close();
|
|
if let Some(preview_binding) = widgets_handle.display_panes[monitor_id]
|
|
.preview_binding
|
|
.as_ref()
|
|
{
|
|
preview_binding.set_enabled(true);
|
|
}
|
|
state_handle
|
|
.borrow_mut()
|
|
.set_display_surface(monitor_id, DisplaySurface::Preview);
|
|
refresh_launcher_ui(
|
|
&widgets_handle,
|
|
&state_handle.borrow(),
|
|
child_proc_handle.borrow().is_some(),
|
|
);
|
|
} else {
|
|
close_binding.close();
|
|
}
|
|
glib::Propagation::Proceed
|
|
});
|
|
|
|
state
|
|
.borrow_mut()
|
|
.set_display_surface(monitor_id, DisplaySurface::Window);
|
|
popouts.borrow_mut()[monitor_id] = Some(PopoutWindowHandle {
|
|
window: window.clone(),
|
|
binding,
|
|
});
|
|
refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
window.present();
|
|
}
|
|
|
|
pub fn dock_display_to_preview(
|
|
state: &Rc<RefCell<LauncherState>>,
|
|
child_proc: &Rc<RefCell<Option<Child>>>,
|
|
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
|
|
widgets: &LauncherWidgets,
|
|
monitor_id: usize,
|
|
) {
|
|
let handle = {
|
|
let mut popouts = popouts.borrow_mut();
|
|
popouts[monitor_id].take()
|
|
};
|
|
if let Some(handle) = handle {
|
|
handle.binding.close();
|
|
handle.window.close();
|
|
}
|
|
if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() {
|
|
binding.set_enabled(true);
|
|
}
|
|
state
|
|
.borrow_mut()
|
|
.set_display_surface(monitor_id, DisplaySurface::Preview);
|
|
refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
}
|
|
|
|
pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) {
|
|
if let Some(binding) = pane.preview_binding.as_ref() {
|
|
binding.set_enabled(matches!(surface, DisplaySurface::Preview));
|
|
}
|
|
pane.action_button
|
|
.set_sensitive(pane.preview_binding.is_some());
|
|
match surface {
|
|
DisplaySurface::Preview => {
|
|
pane.stack.set_visible_child_name("preview");
|
|
pane.action_button.set_label("Break Out");
|
|
pane.placeholder.set_text(
|
|
"This feed is running in its own window.\nUse Return To Preview to dock it back here.",
|
|
);
|
|
if pane.preview_binding.is_none() {
|
|
pane.stream_status.set_text("Preview unavailable");
|
|
}
|
|
}
|
|
DisplaySurface::Window => {
|
|
pane.stack.set_visible_child_name("placeholder");
|
|
pane.action_button.set_label("Return To Preview");
|
|
pane.placeholder.set_text(&format!(
|
|
"{} is running in a dedicated window.\nReturn it here when you want the in-launcher preview back.",
|
|
pane.title
|
|
));
|
|
pane.stream_status.set_text("Streaming in its own window");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn capture_power_label(power: &CapturePowerStatus) -> String {
|
|
if !power.available {
|
|
return "Unavailable".to_string();
|
|
}
|
|
match power.mode.as_str() {
|
|
"forced-on" => "Forced On".to_string(),
|
|
"forced-off" => "Forced Off".to_string(),
|
|
_ => {
|
|
if power.enabled {
|
|
"Auto • Live".to_string()
|
|
} else {
|
|
"Auto • Standby".to_string()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn capture_power_detail(power: &CapturePowerStatus) -> String {
|
|
if !power.available {
|
|
return format!("{} is unavailable: {}", power.unit, power.detail);
|
|
}
|
|
match power.mode.as_str() {
|
|
"forced-on" => format!(
|
|
"{} • manual override holding feeds up • {} • leases {}",
|
|
power.unit, power.detail, power.active_leases
|
|
),
|
|
"forced-off" => format!(
|
|
"{} • manual override holding feeds down • {} • leases {}",
|
|
power.unit, power.detail, power.active_leases
|
|
),
|
|
_ => format!(
|
|
"{} • automatic mode follows live previews and session demand • {} • leases {}",
|
|
power.unit, power.detail, power.active_leases
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Highlights the currently active capture mode so it reads like a segmented control.
|
|
fn sync_power_mode_button_styles(widgets: &LauncherWidgets, mode: &str) {
|
|
for button in [
|
|
&widgets.power_auto_button,
|
|
&widgets.power_on_button,
|
|
&widgets.power_off_button,
|
|
] {
|
|
button.remove_css_class("pill-toggle-active");
|
|
}
|
|
match mode {
|
|
"forced-on" => widgets.power_on_button.add_css_class("pill-toggle-active"),
|
|
"forced-off" => widgets.power_off_button.add_css_class("pill-toggle-active"),
|
|
_ => widgets
|
|
.power_auto_button
|
|
.add_css_class("pill-toggle-active"),
|
|
}
|
|
}
|
|
|
|
/// Summarizes the current staging state as a short operator-facing heading.
|
|
fn launch_plan_title(state: &LauncherState, child_running: bool) -> String {
|
|
if child_running || state.remote_active {
|
|
return match state.capture_power.mode.as_str() {
|
|
"forced-off" => "Relay live, but capture is intentionally dark.".to_string(),
|
|
"forced-on" => "Relay live with capture held awake.".to_string(),
|
|
_ => "Relay live with automatic capture management.".to_string(),
|
|
};
|
|
}
|
|
|
|
match state.capture_power.mode.as_str() {
|
|
"forced-off" => "Staging mode is holding capture off.".to_string(),
|
|
"forced-on" => "Capture is pre-warmed for staging.".to_string(),
|
|
_ => "Stage locally, then start the relay.".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Shows the exact devices the next relay launch will inherit.
|
|
fn launch_plan_summary(state: &LauncherState) -> String {
|
|
format!(
|
|
"Camera: {}\nMicrophone: {}\nSpeaker: {}",
|
|
selected_device_label(state.devices.camera.as_deref()),
|
|
selected_device_label(state.devices.microphone.as_deref()),
|
|
selected_device_label(state.devices.speaker.as_deref())
|
|
)
|
|
}
|
|
|
|
/// Explains the consequence of the current capture and session state.
|
|
fn launch_plan_detail(state: &LauncherState, child_running: bool) -> String {
|
|
if child_running || state.remote_active {
|
|
return match state.capture_power.mode.as_str() {
|
|
"forced-off" => format!(
|
|
"Inputs are routed to {}. Return capture to Auto or Force On when you want the remote eyes and session video to wake up.",
|
|
capitalize(routing_name(state.routing))
|
|
),
|
|
"forced-on" => format!(
|
|
"Inputs are routed to {}. The relay host is keeping the capture feeds up even without preview demand.",
|
|
capitalize(routing_name(state.routing))
|
|
),
|
|
_ => format!(
|
|
"Inputs are routed to {}. Live eye previews and session demand will wake capture automatically as needed.",
|
|
capitalize(routing_name(state.routing))
|
|
),
|
|
};
|
|
}
|
|
|
|
if !state.capture_power.available {
|
|
return format!(
|
|
"Capture power status from {} is unavailable right now. You can still stage devices locally before you try the relay host again.",
|
|
state.capture_power.unit
|
|
);
|
|
}
|
|
|
|
match state.capture_power.mode.as_str() {
|
|
"forced-off" => {
|
|
"Remote eye previews and the next relay session will stay dark until you return to Auto or Force On."
|
|
.to_string()
|
|
}
|
|
"forced-on" => {
|
|
"The relay host is already holding capture awake, which is useful for preflight framing checks before the session starts."
|
|
.to_string()
|
|
}
|
|
_ => {
|
|
"Automatic capture mode wakes the remote feeds only while eye previews or the live relay session actually need them."
|
|
.to_string()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reports which local staging checks are active right now.
|
|
fn local_test_detail(
|
|
camera_running: bool,
|
|
microphone_running: bool,
|
|
speaker_running: bool,
|
|
) -> String {
|
|
let mut active = Vec::new();
|
|
if camera_running {
|
|
active.push("camera preview");
|
|
}
|
|
if microphone_running {
|
|
active.push("mic monitor");
|
|
}
|
|
if speaker_running {
|
|
active.push("speaker tone");
|
|
}
|
|
|
|
if active.is_empty() {
|
|
"Local checks are idle. Use Start Preview, Monitor Mic, or Play Tone before you launch."
|
|
.to_string()
|
|
} else {
|
|
format!(
|
|
"Local checks running: {}. Stop them whenever staging is complete.",
|
|
active.join(", ")
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Formats a selected device for the launch-plan summary.
|
|
fn selected_device_label(value: Option<&str>) -> String {
|
|
value
|
|
.map(compact_device_name)
|
|
.unwrap_or_else(|| "auto".to_string())
|
|
}
|
|
|
|
/// Prefer the basename for `/dev/...` entries while keeping Pulse names intact.
|
|
fn compact_device_name(value: &str) -> String {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return "auto".to_string();
|
|
}
|
|
trimmed.rsplit('/').next().unwrap_or(trimmed).to_string()
|
|
}
|
|
|
|
pub fn capitalize(value: &str) -> String {
|
|
let mut chars = value.chars();
|
|
match chars.next() {
|
|
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
pub fn selected_combo_value(combo: >k::ComboBoxText) -> Option<String> {
|
|
combo.active_text().and_then(|value| {
|
|
let value = value.to_string();
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn selected_toggle_key(combo: >k::ComboBoxText) -> String {
|
|
combo
|
|
.active_id()
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "pause".to_string())
|
|
}
|
|
|
|
pub fn selected_toggle_key_label(combo: >k::ComboBoxText) -> String {
|
|
combo
|
|
.active_text()
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "Pause".to_string())
|
|
}
|
|
|
|
pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String {
|
|
let current = entry.text();
|
|
let trimmed = current.trim();
|
|
if trimmed.is_empty() {
|
|
fallback.to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn input_control_path() -> PathBuf {
|
|
std::env::var(INPUT_CONTROL_ENV)
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH))
|
|
}
|
|
|
|
pub fn input_state_path() -> PathBuf {
|
|
std::env::var(INPUT_STATE_ENV)
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH))
|
|
}
|
|
|
|
pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> {
|
|
std::fs::write(path, format!("{}\n", routing_name(routing)))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn read_input_routing_state(path: &Path) -> Option<InputRouting> {
|
|
let raw = std::fs::read_to_string(path).ok()?;
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
"local" => Some(InputRouting::Local),
|
|
"remote" => Some(InputRouting::Remote),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn routing_name(routing: InputRouting) -> &'static str {
|
|
match routing {
|
|
InputRouting::Local => "local",
|
|
InputRouting::Remote => "remote",
|
|
}
|
|
}
|
|
|
|
pub fn path_marker(path: &Path) -> u128 {
|
|
std::fs::metadata(path)
|
|
.ok()
|
|
.and_then(|meta| meta.modified().ok())
|
|
.and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|duration| duration.as_millis())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn set_combo_active_text(combo: >k::ComboBoxText, wanted: Option<&str>) {
|
|
let wanted = wanted.unwrap_or("auto");
|
|
if !combo.set_active_id(Some(wanted)) {
|
|
let _ = combo.set_active_id(Some("auto"));
|
|
}
|
|
}
|
|
|
|
pub fn spawn_client_process(
|
|
server_addr: &str,
|
|
state: &LauncherState,
|
|
input_toggle_key: &str,
|
|
input_control_path: &Path,
|
|
input_state_path: &Path,
|
|
) -> Result<Child> {
|
|
let exe = std::env::current_exe()?;
|
|
let mut command = Command::new(exe);
|
|
command.env("LESAVKA_LAUNCHER_CHILD", "1");
|
|
command.env("LESAVKA_SERVER_ADDR", server_addr);
|
|
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
|
|
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher");
|
|
command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1");
|
|
command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path());
|
|
command.env(INPUT_CONTROL_ENV, input_control_path);
|
|
command.env(INPUT_STATE_ENV, input_state_path);
|
|
command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1");
|
|
command.env("LESAVKA_CLIPBOARD_PASTE", "0");
|
|
for (key, value) in runtime_env_vars(state) {
|
|
command.env(key, value);
|
|
}
|
|
Ok(command.spawn()?)
|
|
}
|
|
|
|
pub fn stop_child_process(child_proc: &Rc<RefCell<Option<Child>>>) {
|
|
if let Some(mut child) = child_proc.borrow_mut().take() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
}
|
|
|
|
pub fn reap_exited_child(child_proc: &Rc<RefCell<Option<Child>>>) -> bool {
|
|
let mut slot = child_proc.borrow_mut();
|
|
match slot.as_mut() {
|
|
Some(child) => match child.try_wait() {
|
|
Ok(Some(_)) => {
|
|
*slot = None;
|
|
false
|
|
}
|
|
Ok(None) | Err(_) => true,
|
|
},
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
pub fn next_input_routing(routing: InputRouting) -> InputRouting {
|
|
match routing {
|
|
InputRouting::Remote => InputRouting::Local,
|
|
InputRouting::Local => InputRouting::Remote,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn launch_plan_summary_compacts_selected_devices() {
|
|
let mut state = LauncherState::new();
|
|
state.select_camera(Some(
|
|
"/dev/v4l/by-id/usb-Logitech_C920-video-index0".to_string(),
|
|
));
|
|
state.select_microphone(Some("alsa_input.usb-focusrite".to_string()));
|
|
state.select_speaker(Some("alsa_output.studio".to_string()));
|
|
|
|
let summary = launch_plan_summary(&state);
|
|
assert!(summary.contains("usb-Logitech_C920-video-index0"));
|
|
assert!(summary.contains("alsa_input.usb-focusrite"));
|
|
assert!(summary.contains("alsa_output.studio"));
|
|
assert!(!summary.contains("/dev/v4l/by-id/"));
|
|
}
|
|
|
|
#[test]
|
|
fn launch_plan_detail_calls_out_forced_off_sessions() {
|
|
let mut state = LauncherState::new();
|
|
state.set_capture_power(CapturePowerStatus {
|
|
available: true,
|
|
enabled: false,
|
|
unit: "relay.service".to_string(),
|
|
detail: "inactive/dead".to_string(),
|
|
active_leases: 0,
|
|
mode: "forced-off".to_string(),
|
|
});
|
|
state.start_remote();
|
|
|
|
let detail = launch_plan_detail(&state, true);
|
|
assert!(detail.contains("Return capture to Auto or Force On"));
|
|
}
|
|
|
|
#[test]
|
|
fn local_test_detail_mentions_idle_and_running_modes() {
|
|
assert!(local_test_detail(false, false, false).contains("idle"));
|
|
let running = local_test_detail(true, true, false);
|
|
assert!(running.contains("camera preview"));
|
|
assert!(running.contains("mic monitor"));
|
|
}
|
|
|
|
#[test]
|
|
fn compact_device_name_prefers_basename_when_available() {
|
|
assert_eq!(compact_device_name("/dev/video0"), "video0");
|
|
assert_eq!(compact_device_name("alsa_input.usb"), "alsa_input.usb");
|
|
}
|
|
}
|