use serde::{Deserialize, Serialize}; use super::devices::DeviceCatalog; use lesavka_common::eye_source::{ EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InputRouting { Local, Remote, } impl InputRouting { pub fn as_env(self) -> &'static str { match self { Self::Local => "0", Self::Remote => "1", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ViewMode { Unified, Breakout, } impl ViewMode { pub fn as_env(self) -> &'static str { match self { Self::Unified => "unified", Self::Breakout => "breakout", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DisplaySurface { Preview, Window, } impl DisplaySurface { pub fn label(self) -> &'static str { match self { Self::Preview => "preview", Self::Window => "window", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum FeedSourcePreset { ThisEye, OtherEye, Off, } impl FeedSourcePreset { pub fn as_id(self) -> &'static str { match self { Self::ThisEye => "self", Self::OtherEye => "other", Self::Off => "off", } } pub fn from_id(raw: &str) -> Option { match raw { "self" => Some(Self::ThisEye), "other" => Some(Self::OtherEye), "off" => Some(Self::Off), _ => None, } } pub fn label(self, monitor_id: usize) -> &'static str { match (monitor_id, self) { (_, Self::Off) => "Off", (0, Self::ThisEye) => "Left Eye", (0, Self::OtherEye) => "Right Eye", (1, Self::ThisEye) => "Right Eye", (1, Self::OtherEye) => "Left Eye", _ => "This Eye", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BreakoutSizePreset { P360, P540, P720, P900, P1080, P1440, Source, FillDisplay, } impl BreakoutSizePreset { pub fn as_id(self) -> &'static str { match self { Self::P360 => "360p", Self::P540 => "540p", Self::P720 => "720p", Self::P900 => "900p", Self::P1080 => "1080p", Self::P1440 => "1440p", Self::Source => "source", Self::FillDisplay => "fill", } } pub fn from_id(raw: &str) -> Option { match raw { "360p" => Some(Self::P360), "540p" => Some(Self::P540), "720p" => Some(Self::P720), "900p" => Some(Self::P900), "1080p" => Some(Self::P1080), "1440p" => Some(Self::P1440), "source" => Some(Self::Source), "fill" => Some(Self::FillDisplay), _ => None, } } pub fn label(self) -> &'static str { match self { Self::P360 => "360p", Self::P540 => "540p", Self::P720 => "720p", Self::P900 => "900p", Self::P1080 => "1080p", Self::P1440 => "1440p", Self::Source => "Source", Self::FillDisplay => "Display", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CaptureSizePreset { #[serde(alias = "P360")] Vga, #[serde(alias = "P540")] P480, P576, P720, #[serde(alias = "P900", alias = "P1440", alias = "Source")] P1080, } impl CaptureSizePreset { pub fn as_id(self) -> &'static str { match self { Self::Vga => "vga", Self::P480 => "480p", Self::P576 => "576p", Self::P720 => "720p", Self::P1080 => "1080p", } } pub fn from_id(raw: &str) -> Option { match raw { "vga" | "360p" => Some(Self::Vga), "480p" | "540p" => Some(Self::P480), "576p" => Some(Self::P576), "720p" => Some(Self::P720), "900p" | "1080p" | "1440p" | "source" => Some(Self::P1080), _ => None, } } pub fn label(self) -> &'static str { match self { Self::Vga => "VGA", Self::P480 => "480p", Self::P576 => "576p", Self::P720 => "720p", Self::P1080 => "1080p", } } pub fn transport_label(self) -> &'static str { "device H.264 pass-through" } pub fn source_mode(self) -> EyeSourceMode { match normalize_capture_size_preset(self) { Self::P720 => native_eye_source_modes()[1], Self::P1080 => native_eye_source_modes()[0], Self::Vga | Self::P480 | Self::P576 => native_eye_source_modes()[1], } } pub fn from_source_mode(mode: EyeSourceMode) -> Self { match (mode.width, mode.height, mode.fps) { (1280, 720, 60) => Self::P720, _ => Self::P1080, } } pub fn display_size(self) -> (u32, u32) { display_size_for_source_mode(self.source_mode()) } pub fn display_aspect_ratio(self) -> f32 { let (width, height) = self.display_size(); width.max(1) as f32 / height.max(1) as f32 } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct PreviewSourceSize { pub width: u32, pub height: u32, pub fps: u32, } impl Default for PreviewSourceSize { fn default() -> Self { let mode = default_eye_source_mode(); Self { width: mode.width, height: mode.height, fps: mode.fps, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BreakoutSizeChoice { pub preset: BreakoutSizePreset, pub width: i32, pub height: i32, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CaptureSizeChoice { pub preset: CaptureSizePreset, pub width: i32, pub height: i32, pub fps: u32, pub max_bitrate_kbit: u32, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FeedSourceChoice { pub preset: FeedSourcePreset, pub label: &'static str, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CaptureFpsChoice { pub fps: u32, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CaptureBitrateChoice { pub max_bitrate_kbit: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapturePowerStatus { pub available: bool, pub enabled: bool, pub unit: String, pub detail: String, pub active_leases: u32, pub mode: String, pub detected_devices: u32, } impl Default for CapturePowerStatus { fn default() -> Self { Self { available: false, enabled: false, unit: "relay.service".to_string(), detail: "unknown".to_string(), active_leases: 0, mode: "auto".to_string(), detected_devices: 0, } } } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct DeviceSelection { pub camera: Option, pub microphone: Option, pub speaker: Option, pub keyboard: Option, pub mouse: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherState { pub server_available: bool, pub server_version: Option, pub routing: InputRouting, pub view_mode: ViewMode, pub displays: [DisplaySurface; 2], pub feed_sources: [FeedSourcePreset; 2], pub preview_source: PreviewSourceSize, pub breakout_limit: PreviewSourceSize, pub breakout_display: PreviewSourceSize, pub capture_sizes: [CaptureSizePreset; 2], pub capture_fps: [u32; 2], pub capture_bitrates_kbit: [u32; 2], pub breakout_sizes: [BreakoutSizePreset; 2], pub devices: DeviceSelection, pub swap_key: String, pub swap_key_binding: bool, pub swap_key_binding_token: u64, pub capture_power: CapturePowerStatus, pub remote_active: bool, pub notes: Vec, } impl Default for LauncherState { fn default() -> Self { Self { server_available: false, server_version: None, routing: InputRouting::Remote, view_mode: ViewMode::Unified, displays: [DisplaySurface::Preview, DisplaySurface::Preview], feed_sources: [FeedSourcePreset::ThisEye, FeedSourcePreset::ThisEye], preview_source: PreviewSourceSize::default(), breakout_limit: PreviewSourceSize::default(), breakout_display: PreviewSourceSize::default(), capture_sizes: [CaptureSizePreset::P1080, CaptureSizePreset::P1080], capture_fps: [60, 60], capture_bitrates_kbit: [18_000, 18_000], breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], devices: DeviceSelection::default(), swap_key: "pause".to_string(), swap_key_binding: false, swap_key_binding_token: 0, capture_power: CapturePowerStatus::default(), remote_active: false, notes: Vec::new(), } } } impl LauncherState { pub fn new() -> Self { Self::default() } pub fn set_routing(&mut self, routing: InputRouting) { self.routing = routing; } pub fn set_server_available(&mut self, available: bool) { self.server_available = available; } pub fn set_server_version(&mut self, version: Option) { self.server_version = version.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); } pub fn set_view_mode(&mut self, view_mode: ViewMode) { self.view_mode = view_mode; self.displays = match view_mode { ViewMode::Unified => [DisplaySurface::Preview, DisplaySurface::Preview], ViewMode::Breakout => [DisplaySurface::Window, DisplaySurface::Window], }; } pub fn display_surface(&self, monitor_id: usize) -> DisplaySurface { self.displays .get(monitor_id) .copied() .unwrap_or(DisplaySurface::Preview) } pub fn feed_source_preset(&self, monitor_id: usize) -> FeedSourcePreset { self.feed_sources .get(monitor_id) .copied() .unwrap_or(FeedSourcePreset::ThisEye) } pub fn set_feed_source_preset(&mut self, monitor_id: usize, preset: FeedSourcePreset) { if let Some(slot) = self.feed_sources.get_mut(monitor_id) { *slot = preset; } } pub fn feed_source_options(&self, monitor_id: usize) -> Vec { vec![ FeedSourceChoice { preset: FeedSourcePreset::ThisEye, label: FeedSourcePreset::ThisEye.label(monitor_id), }, FeedSourceChoice { preset: FeedSourcePreset::OtherEye, label: FeedSourcePreset::OtherEye.label(monitor_id), }, FeedSourceChoice { preset: FeedSourcePreset::Off, label: FeedSourcePreset::Off.label(monitor_id), }, ] } pub fn resolved_feed_monitor_id(&self, monitor_id: usize) -> Option { match self.feed_source_preset(monitor_id) { FeedSourcePreset::ThisEye => Some(monitor_id.min(1)), FeedSourcePreset::OtherEye => Some(1_usize.saturating_sub(monitor_id.min(1))), FeedSourcePreset::Off => None, } } pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) { if let Some(slot) = self.displays.get_mut(monitor_id) { *slot = surface; self.view_mode = if self .displays .iter() .any(|display| matches!(display, DisplaySurface::Window)) { ViewMode::Breakout } else { ViewMode::Unified }; } } pub fn breakout_count(&self) -> usize { self.displays .iter() .filter(|surface| matches!(surface, DisplaySurface::Window)) .count() } pub fn preview_source_size(&self) -> PreviewSourceSize { self.preview_source } pub fn set_preview_source_profile(&mut self, width: u32, height: u32, fps: u32) { if width == 0 || height == 0 { return; } self.preview_source = PreviewSourceSize { width, height, fps: fps.max(1), }; } pub fn breakout_limit_size(&self) -> PreviewSourceSize { self.breakout_limit } pub fn set_breakout_limit_size(&mut self, width: u32, height: u32) { if width == 0 || height == 0 { return; } self.breakout_limit = PreviewSourceSize { width, height, fps: self.breakout_limit.fps.max(1), }; } pub fn breakout_display_size(&self) -> PreviewSourceSize { self.breakout_display } pub fn set_breakout_display_size(&mut self, width: u32, height: u32) { if width == 0 || height == 0 { return; } self.breakout_display = PreviewSourceSize { width, height, fps: self.breakout_display.fps.max(1), }; } pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset { normalize_capture_size_preset( self.capture_sizes .get(monitor_id) .copied() .unwrap_or(CaptureSizePreset::P1080), ) } pub fn display_capture_size_preset(&self, monitor_id: usize) -> Option { self.resolved_feed_monitor_id(monitor_id) .map(|source_id| self.capture_size_preset(source_id)) } pub fn set_capture_size_preset(&mut self, monitor_id: usize, preset: CaptureSizePreset) { let preset = normalize_capture_size_preset(preset); if let Some(slot) = self.capture_sizes.get_mut(monitor_id) { *slot = preset; } let defaults = default_profile_for_preset(self.preview_source, preset); self.set_capture_fps(monitor_id, defaults.fps); self.set_capture_bitrate_kbit(monitor_id, defaults.max_bitrate_kbit); } pub fn capture_fps(&self, monitor_id: usize) -> u32 { self.capture_fps .get(monitor_id) .copied() .unwrap_or(default_eye_source_mode().fps) .max(1) } pub fn display_capture_fps(&self, monitor_id: usize) -> Option { self.resolved_feed_monitor_id(monitor_id) .map(|source_id| self.capture_fps(source_id)) } pub fn set_capture_fps(&mut self, monitor_id: usize, fps: u32) { if let Some(slot) = self.capture_fps.get_mut(monitor_id) { *slot = fps.max(1); } } pub fn capture_bitrate_kbit(&self, monitor_id: usize) -> u32 { self.capture_bitrates_kbit .get(monitor_id) .copied() .unwrap_or(estimate_source_bitrate_kbit( default_eye_source_mode().width as i32, default_eye_source_mode().height as i32, default_eye_source_mode().fps, )) .max(800) } pub fn display_capture_bitrate_kbit(&self, monitor_id: usize) -> Option { self.resolved_feed_monitor_id(monitor_id) .map(|source_id| self.capture_bitrate_kbit(source_id)) } pub fn set_capture_bitrate_kbit(&mut self, monitor_id: usize, max_bitrate_kbit: u32) { if let Some(slot) = self.capture_bitrates_kbit.get_mut(monitor_id) { *slot = max_bitrate_kbit.max(800); } } pub fn capture_size_choice(&self, monitor_id: usize) -> CaptureSizeChoice { capture_size_choice( self.preview_source, self.capture_size_preset(monitor_id), self.capture_fps(monitor_id), self.capture_bitrate_kbit(monitor_id), ) } pub fn display_capture_size_choice(&self, monitor_id: usize) -> Option { self.resolved_feed_monitor_id(monitor_id) .map(|source_id| self.capture_size_choice(source_id)) } pub fn effective_preview_source_size(&self, monitor_id: usize) -> PreviewSourceSize { let capture = self .display_capture_size_choice(monitor_id) .unwrap_or_else(|| self.capture_size_choice(monitor_id)); PreviewSourceSize { width: capture.width.max(1) as u32, height: capture.height.max(1) as u32, fps: capture.fps.max(1), } } pub fn capture_size_options(&self) -> Vec { capture_size_options(self.preview_source) } pub fn capture_fps_options(&self) -> Vec { capture_fps_options(self.preview_source) } pub fn capture_bitrate_options(&self) -> Vec { capture_bitrate_options(self.preview_source) } pub fn breakout_size_preset(&self, monitor_id: usize) -> BreakoutSizePreset { self.breakout_sizes .get(monitor_id) .copied() .unwrap_or(BreakoutSizePreset::Source) } pub fn set_breakout_size_preset(&mut self, monitor_id: usize, preset: BreakoutSizePreset) { if let Some(slot) = self.breakout_sizes.get_mut(monitor_id) { *slot = preset; } } pub fn breakout_size_choice(&self, monitor_id: usize) -> BreakoutSizeChoice { breakout_size_choice( self.breakout_limit, self.breakout_display, self.effective_preview_source_size(monitor_id), self.breakout_size_preset(monitor_id), ) } pub fn breakout_size_options(&self, monitor_id: usize) -> Vec { breakout_size_options( self.breakout_limit, self.breakout_display, self.effective_preview_source_size(monitor_id), ) } pub fn select_camera(&mut self, camera: Option) { self.devices.camera = normalize_selection(camera); } pub fn select_microphone(&mut self, microphone: Option) { self.devices.microphone = normalize_selection(microphone); } pub fn select_speaker(&mut self, speaker: Option) { self.devices.speaker = normalize_selection(speaker); } pub fn select_keyboard(&mut self, keyboard: Option) { self.devices.keyboard = normalize_selection(keyboard); } pub fn select_mouse(&mut self, mouse: Option) { self.devices.mouse = normalize_selection(mouse); } pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { let _ = catalog; } pub fn set_swap_key(&mut self, swap_key: impl Into) { self.swap_key = normalize_swap_key(swap_key.into()); } pub fn begin_swap_key_binding(&mut self) -> u64 { self.swap_key_binding = true; self.swap_key_binding_token = self.swap_key_binding_token.wrapping_add(1); self.swap_key_binding_token } pub fn finish_swap_key_binding(&mut self) { self.swap_key_binding = false; } pub fn cancel_swap_key_binding(&mut self, token: u64) -> bool { if self.swap_key_binding && self.swap_key_binding_token == token { self.swap_key_binding = false; true } else { false } } pub fn complete_swap_key_binding(&mut self, swap_key: impl Into) { self.set_swap_key(swap_key); self.finish_swap_key_binding(); } pub fn start_remote(&mut self) -> bool { if self.remote_active { return false; } self.remote_active = true; true } pub fn stop_remote(&mut self) -> bool { if !self.remote_active { return false; } self.remote_active = false; true } pub fn push_note(&mut self, note: impl Into) { self.notes.push(note.into()); } pub fn set_capture_power(&mut self, power: CapturePowerStatus) { self.capture_power = power; } pub fn status_line(&self) -> String { format!( "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}", self.server_available, match self.routing { InputRouting::Local => "local", InputRouting::Remote => "remote", }, match self.view_mode { ViewMode::Unified => "unified", ViewMode::Breakout => "breakout", }, self.remote_active, if self.capture_power.enabled { "on" } else { "off" }, self.preview_source.width, self.preview_source.height, self.displays[0].label(), self.displays[1].label(), self.feed_source_preset(0).as_id(), self.feed_source_preset(1).as_id(), self.devices.camera.as_deref().unwrap_or("auto"), self.devices.microphone.as_deref().unwrap_or("auto"), self.devices.speaker.as_deref().unwrap_or("auto"), self.devices.keyboard.as_deref().unwrap_or("all"), self.devices.mouse.as_deref().unwrap_or("all"), self.swap_key, ) } } fn breakout_size_choice( physical_limit: PreviewSourceSize, display_fill: PreviewSourceSize, source: PreviewSourceSize, preset: BreakoutSizePreset, ) -> BreakoutSizeChoice { let physical_width = physical_limit.width.max(1) as i32; let physical_height = physical_limit.height.max(1) as i32; let display_width = display_fill.width.max(1) as i32; let display_height = display_fill.height.max(1) as i32; let (width, height) = match preset { BreakoutSizePreset::P360 => { fit_standard_dimensions(physical_width, physical_height, 640, 360) } BreakoutSizePreset::P540 => { fit_standard_dimensions(physical_width, physical_height, 960, 540) } BreakoutSizePreset::P720 => { fit_standard_dimensions(physical_width, physical_height, 1280, 720) } BreakoutSizePreset::P900 => { fit_standard_dimensions(physical_width, physical_height, 1600, 900) } BreakoutSizePreset::P1080 => { fit_standard_dimensions(physical_width, physical_height, 1920, 1080) } BreakoutSizePreset::P1440 => { fit_standard_dimensions(physical_width, physical_height, 2560, 1440) } BreakoutSizePreset::Source => fit_standard_dimensions( physical_width, physical_height, source.width.max(1) as i32, source.height.max(1) as i32, ), BreakoutSizePreset::FillDisplay => (display_width, display_height), }; BreakoutSizeChoice { preset, width, height, } } fn breakout_size_options( physical_limit: PreviewSourceSize, display_fill: PreviewSourceSize, source: PreviewSourceSize, ) -> Vec { let mut options = Vec::new(); for preset in [ BreakoutSizePreset::Source, BreakoutSizePreset::P360, BreakoutSizePreset::P540, BreakoutSizePreset::P720, BreakoutSizePreset::P900, BreakoutSizePreset::P1080, BreakoutSizePreset::P1440, BreakoutSizePreset::FillDisplay, ] { let choice = breakout_size_choice(physical_limit, display_fill, source, preset); let allow_duplicate_label = matches!( preset, BreakoutSizePreset::Source | BreakoutSizePreset::FillDisplay ); if !allow_duplicate_label && options.iter().any(|existing: &BreakoutSizeChoice| { existing.width == choice.width && existing.height == choice.height }) { continue; } options.push(choice); } options } fn capture_size_choice( _source: PreviewSourceSize, preset: CaptureSizePreset, selected_fps: u32, selected_bitrate_kbit: u32, ) -> CaptureSizeChoice { let preset = normalize_capture_size_preset(preset); let mode = preset.source_mode(); let _ = (selected_fps, selected_bitrate_kbit); let (width, height, fps, max_bitrate_kbit) = ( mode.width as i32, mode.height as i32, mode.fps, estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps), ); CaptureSizeChoice { preset, width, height, fps, max_bitrate_kbit, } } fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 { let pixels_per_second = width.max(1) as u64 * height.max(1) as u64 * fps.max(1) as u64; match pixels_per_second { p if p >= 1920_u64 * 1080 * 50 => 18_000, p if p >= 1920_u64 * 1080 * 24 => 12_000, p if p >= 1280_u64 * 720 * 24 => 6_000, _ => 2_500, } } fn capture_size_options(source: PreviewSourceSize) -> Vec { native_eye_source_modes() .iter() .copied() .filter(|mode| mode.width <= source.width && mode.height <= source.height) .map(CaptureSizePreset::from_source_mode) .map(|preset| { let defaults = default_profile_for_preset(source, preset); capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit) }) .collect() } fn capture_fps_options(source: PreviewSourceSize) -> Vec { vec![CaptureFpsChoice { fps: source.fps.max(1), }] } fn capture_bitrate_options(source: PreviewSourceSize) -> Vec { vec![CaptureBitrateChoice { max_bitrate_kbit: estimate_source_bitrate_kbit( source.width as i32, source.height as i32, source.fps, ), }] } fn default_profile_for_preset( _source: PreviewSourceSize, preset: CaptureSizePreset, ) -> CaptureSizeChoice { let preset = normalize_capture_size_preset(preset); let mode = preset.source_mode(); let (width, height, fps, max_bitrate_kbit) = ( mode.width as i32, mode.height as i32, mode.fps, estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps), ); CaptureSizeChoice { preset, width, height, fps, max_bitrate_kbit, } } fn normalize_capture_size_preset(preset: CaptureSizePreset) -> CaptureSizePreset { match preset { CaptureSizePreset::Vga | CaptureSizePreset::P480 | CaptureSizePreset::P576 => { CaptureSizePreset::P720 } other => other, } } fn fit_standard_dimensions( limit_width: i32, limit_height: i32, wanted_width: i32, wanted_height: i32, ) -> (i32, i32) { let width = wanted_width.min(limit_width).max(2); let height = wanted_height.min(limit_height).max(2); if width == limit_width && height == limit_height { return (width, height); } let width_from_height = round_down_even((height * 16) / 9); if width_from_height <= limit_width { (round_down_even(width_from_height), round_down_even(height)) } else { let height_from_width = round_down_even((width * 9) / 16); (round_down_even(width), round_down_even(height_from_width)) } } fn round_down_even(value: i32) -> i32 { let rounded = value.max(2); rounded - (rounded % 2) } fn normalize_selection(value: Option) -> Option { value.and_then(|v| { let trimmed = v.trim(); if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { None } else { Some(trimmed.to_string()) } }) } fn normalize_swap_key(value: String) -> String { let trimmed = value.trim(); if trimmed.is_empty() { "off".to_string() } else { trimmed.to_ascii_lowercase() } } #[cfg(test)] mod tests { use super::*; #[test] fn routing_and_view_env_values_are_stable() { assert_eq!(InputRouting::Local.as_env(), "0"); assert_eq!(InputRouting::Remote.as_env(), "1"); assert_eq!(ViewMode::Unified.as_env(), "unified"); assert_eq!(ViewMode::Breakout.as_env(), "breakout"); } #[test] fn defaults_pick_remote_unified_and_inactive_session() { let state = LauncherState::new(); assert_eq!(state.routing, InputRouting::Remote); assert_eq!(state.view_mode, ViewMode::Unified); assert_eq!(state.display_surface(0), DisplaySurface::Preview); assert_eq!(state.display_surface(1), DisplaySurface::Preview); assert_eq!(state.preview_source_size(), PreviewSourceSize::default()); assert_eq!(state.breakout_limit_size(), PreviewSourceSize::default()); assert_eq!(state.capture_size_preset(0), CaptureSizePreset::P1080); assert_eq!(state.breakout_size_preset(0), BreakoutSizePreset::Source); assert!(!state.server_available); assert!(!state.remote_active); assert!(state.devices.camera.is_none()); assert!(state.devices.microphone.is_none()); assert!(state.devices.speaker.is_none()); assert!(state.devices.keyboard.is_none()); assert!(state.devices.mouse.is_none()); assert_eq!(state.capture_power.unit, "relay.service"); assert_eq!(state.capture_power.mode, "auto"); } #[test] fn display_surface_updates_global_view_summary() { let mut state = LauncherState::new(); state.set_display_surface(1, DisplaySurface::Window); assert_eq!(state.view_mode, ViewMode::Breakout); assert_eq!(state.breakout_count(), 1); state.set_display_surface(1, DisplaySurface::Preview); assert_eq!(state.view_mode, ViewMode::Unified); assert_eq!(state.breakout_count(), 0); state.set_view_mode(ViewMode::Breakout); assert_eq!(state.display_surface(0), DisplaySurface::Window); assert_eq!(state.display_surface(1), DisplaySurface::Window); } #[test] fn feed_sources_can_mirror_or_disable_a_pane() { let mut state = LauncherState::new(); state.set_capture_size_preset(1, CaptureSizePreset::P1080); assert_eq!(state.resolved_feed_monitor_id(0), Some(0)); assert_eq!(state.resolved_feed_monitor_id(1), Some(1)); state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); assert_eq!(state.resolved_feed_monitor_id(0), Some(1)); assert_eq!( state.display_capture_size_choice(0), Some(state.capture_size_choice(1)) ); state.set_feed_source_preset(1, FeedSourcePreset::OtherEye); assert_eq!(state.resolved_feed_monitor_id(1), Some(0)); state.set_feed_source_preset(0, FeedSourcePreset::Off); assert_eq!(state.resolved_feed_monitor_id(0), None); assert!(state.display_capture_size_choice(0).is_none()); } #[test] fn mirrored_panes_use_their_effective_source_size_for_breakout_source_labels() { let mut state = LauncherState::new(); state.set_capture_size_preset(1, CaptureSizePreset::P720); state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); let mirrored_source = state.effective_preview_source_size(0); assert_eq!(mirrored_source.width, 1280); assert_eq!(mirrored_source.height, 720); assert_eq!(mirrored_source.fps, 60); let mirrored_breakout = state.breakout_size_choice(0); assert_eq!(mirrored_breakout.preset, BreakoutSizePreset::Source); assert_eq!(mirrored_breakout.width, 1280); assert_eq!(mirrored_breakout.height, 720); } #[test] fn selecting_auto_or_blank_clears_explicit_device() { let mut state = LauncherState::new(); state.select_camera(Some("/dev/video0".to_string())); assert_eq!(state.devices.camera.as_deref(), Some("/dev/video0")); state.select_camera(Some("auto".to_string())); assert!(state.devices.camera.is_none()); state.select_microphone(Some(" ".to_string())); assert!(state.devices.microphone.is_none()); } #[test] fn catalog_defaults_do_not_auto_stage_media_devices() { let mut state = LauncherState::new(); state.select_camera(Some("/dev/video-special".to_string())); let catalog = DeviceCatalog { cameras: vec!["/dev/video0".to_string()], microphones: vec!["alsa_input.usb".to_string()], speakers: vec!["alsa_output.usb".to_string()], keyboards: vec!["/dev/input/event10".to_string()], mice: vec!["/dev/input/event11".to_string()], }; state.apply_catalog_defaults(&catalog); assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special")); assert!(state.devices.microphone.is_none()); assert!(state.devices.speaker.is_none()); let mut fresh = LauncherState::new(); fresh.apply_catalog_defaults(&catalog); assert!(fresh.devices.camera.is_none()); assert!(fresh.devices.microphone.is_none()); assert!(fresh.devices.speaker.is_none()); } #[test] fn start_and_stop_remote_only_report_changes_once() { let mut state = LauncherState::new(); assert!(state.start_remote()); assert!(!state.start_remote()); assert!(state.remote_active); assert!(state.stop_remote()); assert!(!state.stop_remote()); assert!(!state.remote_active); } #[test] fn status_line_mentions_all_user_visible_controls() { let mut state = LauncherState::new(); state.set_server_available(true); state.set_routing(InputRouting::Local); state.set_view_mode(ViewMode::Unified); state.select_camera(Some("/dev/video0".to_string())); state.select_microphone(Some("alsa_input.usb".to_string())); state.select_speaker(Some("alsa_output.usb".to_string())); state.select_keyboard(Some("/dev/input/event-kbd".to_string())); state.select_mouse(Some("/dev/input/event-mouse".to_string())); state.set_preview_source_profile(1920, 1080, 30); state.start_remote(); let status = state.status_line(); assert!(status.contains("mode=local")); assert!(status.contains("server=true")); assert!(status.contains("view=unified")); assert!(status.contains("active=true")); assert!(status.contains("source=1920x1080")); assert!(status.contains("d1=preview")); assert!(status.contains("d2=preview")); assert!(status.contains("camera=/dev/video0")); assert!(status.contains("mic=alsa_input.usb")); assert!(status.contains("speaker=alsa_output.usb")); assert!(status.contains("kbd=/dev/input/event-kbd")); assert!(status.contains("mouse=/dev/input/event-mouse")); } #[test] fn capture_power_status_updates_snapshot_state() { let mut state = LauncherState::new(); state.set_capture_power(CapturePowerStatus { available: true, enabled: true, unit: "relay.service".to_string(), detail: "active/running".to_string(), active_leases: 2, mode: "forced-on".to_string(), detected_devices: 2, }); assert!(state.capture_power.available); assert!(state.capture_power.enabled); assert_eq!(state.capture_power.active_leases, 2); assert!(state.status_line().contains("power=on")); } #[test] fn server_availability_tracks_reachability() { let mut state = LauncherState::new(); assert!(!state.server_available); state.set_server_available(true); assert!(state.server_available); } #[test] fn breakout_size_choices_track_the_negotiated_source_size() { let mut state = LauncherState::new(); state.set_preview_source_profile(1920, 1080, 60); state.set_breakout_limit_size(2560, 1440); let source = state.capture_size_choice(0); assert_eq!(source.width, 1920); assert_eq!(source.height, 1080); assert_eq!(source.fps, 60); assert_eq!(source.max_bitrate_kbit, 18_000); state.set_capture_size_preset(0, CaptureSizePreset::P480); let compact_capture = state.capture_size_choice(0); assert_eq!(compact_capture.preset, CaptureSizePreset::P720); assert_eq!(compact_capture.width, 1280); assert_eq!(compact_capture.height, 720); assert_eq!(compact_capture.fps, 60); assert_eq!(compact_capture.max_bitrate_kbit, 12_000); let effective_source = state.effective_preview_source_size(0); assert_eq!(effective_source.width, 1280); assert_eq!(effective_source.height, 720); assert_eq!(effective_source.fps, 60); let display = state.breakout_size_choice(0); assert_eq!(display.width, 1280); assert_eq!(display.height, 720); state.set_breakout_size_preset(0, BreakoutSizePreset::P360); let smaller = state.breakout_size_choice(0); assert_eq!(smaller.width, 640); assert_eq!(smaller.height, 360); state.set_breakout_size_preset(0, BreakoutSizePreset::P540); let compact = state.breakout_size_choice(0); assert_eq!(compact.width, 960); assert_eq!(compact.height, 540); let capture_options = state.capture_size_options(); assert_eq!(capture_options.len(), 2); assert_eq!(capture_options[0].preset, CaptureSizePreset::P1080); assert_eq!(capture_options[0].width, 1920); assert_eq!(capture_options[0].height, 1080); assert_eq!(capture_options[0].fps, 60); assert_eq!(capture_options[0].max_bitrate_kbit, 18_000); let breakout_options = state.breakout_size_options(0); assert!(breakout_options.len() >= 5); assert!(breakout_options.iter().any(|choice| { choice.preset == BreakoutSizePreset::Source && choice.width == 1280 && choice.height == 720 })); } #[test] fn swap_key_binding_tracks_selected_key_and_binding_mode() { let mut state = LauncherState::new(); assert_eq!(state.swap_key, "pause"); assert!(!state.swap_key_binding); let token = state.begin_swap_key_binding(); assert!(state.swap_key_binding); assert_eq!(token, state.swap_key_binding_token); state.set_swap_key("F8"); assert_eq!(state.swap_key, "f8"); state.set_swap_key(" "); assert_eq!(state.swap_key, "off"); state.finish_swap_key_binding(); assert!(!state.swap_key_binding); } #[test] fn swap_key_binding_timeout_only_cancels_matching_attempt() { let mut state = LauncherState::new(); let first = state.begin_swap_key_binding(); let second = state.begin_swap_key_binding(); assert!(!state.cancel_swap_key_binding(first)); assert!(state.swap_key_binding); assert!(state.cancel_swap_key_binding(second)); assert!(!state.swap_key_binding); } #[test] fn complete_swap_key_binding_updates_value_and_ends_binding() { let mut state = LauncherState::new(); state.begin_swap_key_binding(); state.complete_swap_key_binding("F12"); assert_eq!(state.swap_key, "f12"); assert!(!state.swap_key_binding); } #[test] fn push_note_accumulates_operator_context() { let mut state = LauncherState::new(); state.push_note("preview warm"); state.push_note("relay linked"); assert_eq!(state.notes, vec!["preview warm", "relay linked"]); } #[test] fn capture_size_presets_map_to_real_device_modes() { let mut state = LauncherState::new(); state.set_preview_source_profile(1920, 1080, 60); state.set_capture_size_preset(0, CaptureSizePreset::P1080); let source = state.capture_size_choice(0); assert_eq!(source.width, 1920); assert_eq!(source.height, 1080); assert_eq!(source.fps, 60); assert!(source.max_bitrate_kbit >= 18_000); state.set_capture_size_preset(0, CaptureSizePreset::P720); let hd = state.capture_size_choice(0); assert_eq!(hd.preset, CaptureSizePreset::P720); assert_eq!(hd.width, 1280); assert_eq!(hd.height, 720); assert_eq!(hd.fps, 60); state.set_capture_size_preset(0, CaptureSizePreset::P576); let compact = state.capture_size_choice(0); assert_eq!(compact.preset, CaptureSizePreset::P720); assert_eq!(compact.width, 1280); assert_eq!(compact.height, 720); assert_eq!(compact.fps, 60); state.set_capture_size_preset(0, CaptureSizePreset::Vga); let small = state.capture_size_choice(0); assert_eq!(small.preset, CaptureSizePreset::P720); assert_eq!(small.width, 1280); assert_eq!(small.height, 720); assert_eq!(small.fps, 60); } #[test] fn source_capture_knobs_follow_the_selected_native_mode() { let mut state = LauncherState::new(); state.set_preview_source_profile(1920, 1080, 60); state.set_capture_size_preset(1, CaptureSizePreset::P1080); let defaults = state.capture_size_choice(1); assert_eq!(defaults.width, 1920); assert_eq!(defaults.height, 1080); assert_eq!(defaults.fps, 60); assert_eq!(defaults.max_bitrate_kbit, 18_000); state.set_capture_fps(1, 24); state.set_capture_bitrate_kbit(1, 8_500); let tuned = state.capture_size_choice(1); assert_eq!(tuned.preset, CaptureSizePreset::P1080); assert_eq!(tuned.width, 1920); assert_eq!(tuned.height, 1080); assert_eq!(tuned.fps, 60); assert_eq!(tuned.max_bitrate_kbit, 18_000); } #[test] fn source_capture_ignores_manual_fps_and_bitrate_knobs() { let mut state = LauncherState::new(); state.set_preview_source_profile(1920, 1080, 60); state.set_capture_size_preset(0, CaptureSizePreset::P720); state.set_capture_fps(0, 60); state.set_capture_bitrate_kbit(0, 24_000); let source = state.capture_size_choice(0); assert_eq!(source.preset, CaptureSizePreset::P720); assert_eq!(source.width, 1280); assert_eq!(source.height, 720); assert_eq!(source.fps, 60); assert_eq!(source.max_bitrate_kbit, 12_000); } }