use serde::{Deserialize, Serialize}; use super::devices::DeviceCatalog; #[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, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct DeviceSelection { pub camera: Option, pub microphone: Option, pub speaker: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherState { pub routing: InputRouting, pub view_mode: ViewMode, pub devices: DeviceSelection, pub remote_active: bool, pub notes: Vec, } impl Default for LauncherState { fn default() -> Self { Self { routing: InputRouting::Local, view_mode: ViewMode::Unified, devices: DeviceSelection::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_view_mode(&mut self, view_mode: ViewMode) { self.view_mode = view_mode; } 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 apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { if self.devices.camera.is_none() { self.devices.camera = catalog.cameras.first().cloned(); } if self.devices.microphone.is_none() { self.devices.microphone = catalog.microphones.first().cloned(); } if self.devices.speaker.is_none() { self.devices.speaker = catalog.speakers.first().cloned(); } } 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 status_line(&self) -> String { format!( "mode={} view={} active={} camera={} mic={} speaker={}", match self.routing { InputRouting::Local => "local", InputRouting::Remote => "remote", }, match self.view_mode { ViewMode::Unified => "unified", ViewMode::Breakout => "breakout", }, self.remote_active, self.devices.camera.as_deref().unwrap_or("auto"), self.devices.microphone.as_deref().unwrap_or("auto"), self.devices.speaker.as_deref().unwrap_or("auto"), ) } } 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()) } }) } #[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_local_unified_and_inactive_session() { let state = LauncherState::new(); assert_eq!(state.routing, InputRouting::Local); assert_eq!(state.view_mode, ViewMode::Unified); assert!(!state.remote_active); assert!(state.devices.camera.is_none()); assert!(state.devices.microphone.is_none()); assert!(state.devices.speaker.is_none()); } #[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_fill_only_missing_values() { 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()], }; state.apply_catalog_defaults(&catalog); assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special")); assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb")); assert_eq!(state.devices.speaker.as_deref(), Some("alsa_output.usb")); } #[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_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.start_remote(); let status = state.status_line(); assert!(status.contains("mode=local")); assert!(status.contains("view=unified")); assert!(status.contains("active=true")); assert!(status.contains("camera=/dev/video0")); assert!(status.contains("mic=alsa_input.usb")); assert!(status.contains("speaker=alsa_output.usb")); } }