diff --git a/linux-rust/src/airpods.rs b/linux-rust/src/airpods.rs index 1566848..21ebed3 100644 --- a/linux-rust/src/airpods.rs +++ b/linux-rust/src/airpods.rs @@ -9,6 +9,7 @@ use ksni::Handle; use tokio::sync::Mutex; use tokio::time::{sleep, Duration}; use crate::ui::tray::MyTray; +use crate::ui::messages::UIMessage; pub struct AirPodsDevice { pub mac_address: Address, @@ -18,7 +19,7 @@ pub struct AirPodsDevice { } impl AirPodsDevice { - pub async fn new(mac_address: Address, tray_handle: Option>) -> Self { + pub async fn new(mac_address: Address, tray_handle: Option>, ui_tx: tokio::sync::mpsc::UnboundedSender) -> Self { info!("Creating new AirPodsDevice for {}", mac_address); let mut aacp_manager = AACPManager::new(); aacp_manager.connect(mac_address).await; @@ -146,7 +147,9 @@ impl AirPodsDevice { let aacp_manager_clone_events = aacp_manager.clone(); let local_mac_events = local_mac.clone(); tokio::spawn(async move { + let ui_tx_clone = ui_tx.clone(); while let Some(event) = rx.recv().await { + let event_clone = event.clone(); match event { AACPEvent::EarDetection(old_status, new_status) => { debug!("Received EarDetection event: old_status={:?}, new_status={:?}", old_status, new_status); @@ -178,9 +181,14 @@ impl AirPodsDevice { }).await; } debug!("Updated tray with new battery info"); + + let _ = ui_tx_clone.send(UIMessage::AACPUIEvent(mac_address.to_string(), event_clone)); + debug!("Sent BatteryInfo event to UI"); } AACPEvent::ControlCommand(status) => { debug!("Received ControlCommand event: {:?}", status); + let _ = ui_tx_clone.send(UIMessage::AACPUIEvent(mac_address.to_string(), event_clone)); + debug!("Sent ControlCommand event to UI"); } AACPEvent::ConversationalAwareness(status) => { debug!("Received ConversationalAwareness event: {}", status); @@ -218,7 +226,11 @@ impl AirPodsDevice { controller.pause_all_media().await; controller.deactivate_a2dp_profile().await; } - _ => {} + _ => { + debug!("Received unhandled AACP event: {:?}", event); + let _ = ui_tx_clone.send(UIMessage::AACPUIEvent(mac_address.to_string(), event_clone)); + debug!("Sent unhandled AACP event to UI"); + } } } }); diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 28f574a..438410d 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -8,7 +8,6 @@ use tokio::time::{sleep, Instant}; use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json; -use std::path::PathBuf; use crate::utils::get_devices_path; const PSM: u16 = 0x1001; @@ -127,6 +126,49 @@ impl ControlCommandIdentifiers { } } +impl std::fmt::Display for ControlCommandIdentifiers { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + ControlCommandIdentifiers::MicMode => "Mic Mode", + ControlCommandIdentifiers::ButtonSendMode => "Button Send Mode", + ControlCommandIdentifiers::VoiceTrigger => "Voice Trigger", + ControlCommandIdentifiers::SingleClickMode => "Single Click Mode", + ControlCommandIdentifiers::DoubleClickMode => "Double Click Mode", + ControlCommandIdentifiers::ClickHoldMode => "Click Hold Mode", + ControlCommandIdentifiers::DoubleClickInterval => "Double Click Interval", + ControlCommandIdentifiers::ClickHoldInterval => "Click Hold Interval", + ControlCommandIdentifiers::ListeningModeConfigs => "Listening Mode Configs", + ControlCommandIdentifiers::OneBudAncMode => "One Bud ANC Mode", + ControlCommandIdentifiers::CrownRotationDirection => "Crown Rotation Direction", + ControlCommandIdentifiers::ListeningMode => "Listening Mode", + ControlCommandIdentifiers::AutoAnswerMode => "Auto Answer Mode", + ControlCommandIdentifiers::ChimeVolume => "Chime Volume", + ControlCommandIdentifiers::VolumeSwipeInterval => "Volume Swipe Interval", + ControlCommandIdentifiers::CallManagementConfig => "Call Management Config", + ControlCommandIdentifiers::VolumeSwipeMode => "Volume Swipe Mode", + ControlCommandIdentifiers::AdaptiveVolumeConfig => "Adaptive Volume Config", + ControlCommandIdentifiers::SoftwareMuteConfig => "Software Mute Config", + ControlCommandIdentifiers::ConversationDetectConfig => "Conversation Detect Config", + ControlCommandIdentifiers::Ssl => "SSL", + ControlCommandIdentifiers::HearingAid => "Hearing Aid", + ControlCommandIdentifiers::AutoAncStrength => "Auto ANC Strength", + ControlCommandIdentifiers::HpsGainSwipe => "HPS Gain Swipe", + ControlCommandIdentifiers::HrmState => "HRM State", + ControlCommandIdentifiers::InCaseToneConfig => "In Case Tone Config", + ControlCommandIdentifiers::SiriMultitoneConfig => "Siri Multitone Config", + ControlCommandIdentifiers::HearingAssistConfig => "Hearing Assist Config", + ControlCommandIdentifiers::AllowOffOption => "Allow Off Option", + ControlCommandIdentifiers::StemConfig => "Stem Config", + ControlCommandIdentifiers::SleepDetectionConfig => "Sleep Detection Config", + ControlCommandIdentifiers::AllowAutoConnect => "Allow Auto Connect", + ControlCommandIdentifiers::EarDetectionConfig => "Ear Detection Config", + ControlCommandIdentifiers::AutomaticConnectionConfig => "Automatic Connection Config", + ControlCommandIdentifiers::OwnsConnection => "Owns Connection", + }; + write!(f, "{}", name) + } +} + #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum ProximityKeyType { diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index 2d2912f..9be27ce 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -19,6 +19,7 @@ use crate::ui::tray::MyTray; use clap::Parser; use crate::bluetooth::le::start_le_monitor; use tokio::sync::mpsc::unbounded_channel; +use crate::ui::messages::UIMessage; #[derive(Parser)] struct Args { @@ -34,11 +35,11 @@ fn main() -> iced::Result { let args = Args::parse(); let log_level = if args.debug { "debug" } else { "info" }; if env::var("RUST_LOG").is_err() { - unsafe { env::set_var("RUST_LOG", log_level); } + unsafe { env::set_var("RUST_LOG", log_level.to_owned() + ",wgpu_core=off,librepods_rust::bluetooth::le=off,cosmic_text=off,naga=off,iced_winit=off") }; } env_logger::init(); - let (ui_tx, ui_rx) = unbounded_channel::<()>(); + let (ui_tx, ui_rx) = unbounded_channel::(); std::thread::spawn(|| { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async_main(ui_tx)).unwrap(); @@ -48,7 +49,7 @@ fn main() -> iced::Result { } -async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<()>) -> bluer::Result<()> { +async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender) -> bluer::Result<()> { let args = Args::parse(); let tray_handle = if args.no_tray { @@ -66,7 +67,7 @@ async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<()>) -> bluer::Res listening_mode: None, allow_off_option: None, command_tx: None, - ui_tx: Some(ui_tx), + ui_tx: Some(ui_tx.clone()), }; let handle = tray.spawn().await.unwrap(); Some(handle) @@ -91,7 +92,9 @@ async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<()>) -> bluer::Res Ok(device) => { let name = device.name().await?.unwrap_or_else(|| "Unknown".to_string()); info!("Found connected AirPods: {}, initializing.", name); - let _airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone()).await; + let ui_tx_clone = ui_tx.clone(); + ui_tx_clone.send(UIMessage::DeviceConnected(device.address().to_string())).unwrap(); + let _airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx_clone).await; } Err(_) => { info!("No connected AirPods found."); @@ -128,8 +131,10 @@ async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<()>) -> bluer::Res let Ok(addr) = addr_str.parse::
() else { return true; }; info!("AirPods connected: {}, initializing", name); let handle_clone = tray_handle.clone(); + let ui_tx_clone = ui_tx.clone(); tokio::spawn(async move { - let _airpods_device = AirPodsDevice::new(addr, handle_clone).await; + ui_tx_clone.send(UIMessage::DeviceConnected(addr_str)).unwrap(); + let _airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone).await; }); true })?; diff --git a/linux-rust/src/ui/messages.rs b/linux-rust/src/ui/messages.rs new file mode 100644 index 0000000..04e777e --- /dev/null +++ b/linux-rust/src/ui/messages.rs @@ -0,0 +1,15 @@ +use crate::bluetooth::aacp::{AACPEvent, ControlCommandIdentifiers}; + +#[derive(Debug, Clone)] +pub enum UIMessage { + OpenWindow, + DeviceConnected(String), + DeviceDisconnected(String), + AACPUIEvent(String, AACPEvent), + NoOp, +} + +#[derive(Debug, Clone)] +pub enum UICommand { + SetControlCommandStatus(String, ControlCommandIdentifiers, Vec), +} \ No newline at end of file diff --git a/linux-rust/src/ui/mod.rs b/linux-rust/src/ui/mod.rs index 42270ce..feca689 100644 --- a/linux-rust/src/ui/mod.rs +++ b/linux-rust/src/ui/mod.rs @@ -1,2 +1,3 @@ pub mod tray; -pub mod window; \ No newline at end of file +pub mod window; +pub mod messages; \ No newline at end of file diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs index b1c05e4..d0aa875 100644 --- a/linux-rust/src/ui/tray.rs +++ b/linux-rust/src/ui/tray.rs @@ -5,6 +5,7 @@ use ksni::{Icon, ToolTip}; use tokio::sync::mpsc::UnboundedSender; use crate::bluetooth::aacp::ControlCommandIdentifiers; +use crate::ui::messages::UIMessage; #[derive(Debug)] pub(crate) struct MyTray { @@ -18,8 +19,8 @@ pub(crate) struct MyTray { pub(crate) connected: bool, pub(crate) listening_mode: Option, pub(crate) allow_off_option: Option, - pub(crate) command_tx: Option)>>, - pub(crate) ui_tx: Option>, + pub(crate) command_tx: Option)>>, + pub(crate) ui_tx: Option>, } impl ksni::Tray for MyTray { @@ -113,7 +114,7 @@ impl ksni::Tray for MyTray { icon_name: "window-new".into(), activate: Box::new(|this: &mut Self| { if let Some(tx) = &this.ui_tx { - let _ = tx.send(()); + let _ = tx.send(UIMessage::OpenWindow); } }), ..Default::default() diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index d1c0634..3bbd1a0 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -1,16 +1,19 @@ + use std::collections::HashMap; use iced::widget::button::Style; use iced::widget::{button, column, container, pane_grid, text, Space, combo_box, row, text_input}; -use iced::{daemon, window, Background, Border, Center, Color, Element, Length, Subscription, Task, Theme}; +use iced::{daemon, window, Background, Border, Center, Color, Element, Length, Size, Subscription, Task, Theme}; use std::sync::Arc; use iced::border::Radius; use iced::overlay::menu; use log::{debug, error}; -use tokio::sync::{mpsc::UnboundedReceiver, Mutex}; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::Mutex; use crate::bluetooth::aacp::{DeviceData, DeviceInformation, DeviceType}; +use crate::ui::messages::UIMessage; use crate::utils::{get_devices_path, get_app_settings_path, MyTheme}; -pub fn start_ui(ui_rx: UnboundedReceiver<()>, start_minimized: bool) -> iced::Result { +pub fn start_ui(ui_rx: UnboundedReceiver, start_minimized: bool) -> iced::Result { daemon(App::title, App::update, App::view) .subscription(App::subscription) .theme(App::theme) @@ -21,9 +24,22 @@ pub struct App { window: Option, panes: pane_grid::State, selected_tab: Tab, - ui_rx: Arc>>, theme_state: combo_box::State, selected_theme: MyTheme, + ui_rx: Arc>>, + bluetooth_state: BluetoothState +} + +pub struct BluetoothState { + connected_devices: Vec +} + +impl BluetoothState { + pub fn new() -> Self { + Self { + connected_devices: Vec::new(), + } + } } #[derive(Debug, Clone)] @@ -32,9 +48,9 @@ pub enum Message { WindowClosed(window::Id), Resized(pane_grid::ResizeEvent), SelectTab(Tab), - OpenMainWindow, ThemeSelected(MyTheme), CopyToClipboard(String), + UIMessage(UIMessage), } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -51,21 +67,25 @@ pub enum Pane { impl App { - pub fn new(ui_rx: UnboundedReceiver<()>, start_minimized: bool) -> (Self, Task) { + pub fn new(ui_rx: UnboundedReceiver, start_minimized: bool) -> (Self, Task) { let (mut panes, first_pane) = pane_grid::State::new(Pane::Sidebar); let split = panes.split(pane_grid::Axis::Vertical, first_pane, Pane::Content); panes.resize(split.unwrap().1, 0.2); let ui_rx = Arc::new(Mutex::new(ui_rx)); + let wait_task = Task::perform( wait_for_message(Arc::clone(&ui_rx)), - |_| Message::OpenMainWindow, + |msg| msg, ); let (window, open_task) = if start_minimized { (None, Task::none()) } else { - let (id, open) = window::open(window::Settings::default()); + let mut settings = window::Settings::default(); + settings.min_size = Some(Size::new(400.0, 300.0)); + settings.icon = window::icon::from_file("../../assets/icon.png").ok(); + let (id, open) = window::open(settings); (Some(id), open.map(Message::WindowOpened)) }; @@ -77,12 +97,13 @@ impl App { .and_then(|t| serde_json::from_value(t).ok()) .unwrap_or(MyTheme::Dark); + let bluetooth_state = BluetoothState::new(); + ( Self { window, panes, selected_tab: Tab::Device("none".to_string()), - ui_rx, theme_state: combo_box::State::new(vec![ MyTheme::Light, MyTheme::Dark, @@ -108,8 +129,10 @@ impl App { MyTheme::Ferra, ]), selected_theme, + ui_rx, + bluetooth_state, }, - Task::batch(vec![open_task, wait_task]), + Task::batch(vec![open_task, wait_task]) ) } @@ -127,11 +150,7 @@ impl App { if self.window == Some(id) { self.window = None; } - let wait_task = Task::perform( - wait_for_message(Arc::clone(&self.ui_rx)), - |_| Message::OpenMainWindow, - ); - wait_task + Task::none() } Message::Resized(event) => { self.panes.resize(event.split, event.ratio); @@ -141,17 +160,6 @@ impl App { self.selected_tab = tab; Task::none() } - Message::OpenMainWindow => { - if let Some(window_id) = self.window { - Task::batch(vec![ - window::gain_focus(window_id), - ]) - } else { - let (new_window_task, open_task) = window::open(window::Settings::default()); - self.window = Some(new_window_task); - open_task.map(Message::WindowOpened) - } - } Message::ThemeSelected(theme) => { self.selected_theme = theme; let app_settings_path = get_app_settings_path(); @@ -163,6 +171,86 @@ impl App { Message::CopyToClipboard(data) => { iced::clipboard::write(data) } + Message::UIMessage(ui_message) => { + match ui_message { + UIMessage::NoOp => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform( + wait_for_message(ui_rx), + |msg| msg, + ); + wait_task + } + UIMessage::OpenWindow => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform( + wait_for_message(ui_rx), + |msg| msg, + ); + debug!("Opening main window..."); + if let Some(window_id) = self.window { + Task::batch(vec![ + window::gain_focus(window_id), + wait_task, + ]) + } else { + let mut settings = window::Settings::default(); + settings.min_size = Some(Size::new(400.0, 300.0)); + settings.icon = window::icon::from_file("../../assets/icon.png").ok(); + let (new_window_task, open_task) = window::open(settings); + self.window = Some(new_window_task); + Task::batch(vec![ + open_task.map(Message::WindowOpened), + wait_task, + ]) + } + } + UIMessage::DeviceConnected(mac) => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform( + wait_for_message(ui_rx), + |msg| msg, + ); + debug!("Device connected: {}. Adding to connected devices list", mac); + let mut already_connected = false; + for device in &self.bluetooth_state.connected_devices { + if device == &mac { + already_connected = true; + break; + } + } + if !already_connected { + self.bluetooth_state.connected_devices.push(mac.clone()); + } + + Task::batch(vec![ + wait_task, + ]) + } + UIMessage::DeviceDisconnected(mac) => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform( + wait_for_message(ui_rx), + |msg| msg, + ); + debug!("Device disconnected: {}", mac); + Task::batch(vec![ + wait_task, + ]) + } + UIMessage::AACPUIEvent(mac, event) => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform( + wait_for_message(ui_rx), + |msg| msg, + ); + debug!("AACP UI Event for {}: {:?}", mac, event); + Task::batch(vec![ + wait_task, + ]) + } + } + } } } @@ -178,30 +266,35 @@ impl App { let pane_grid = pane_grid::PaneGrid::new(&self.panes, |_pane_id, pane, _is_maximized| { match pane { Pane::Sidebar => { - let create_tab_button = |tab: Tab, label: &str, description: Option<&str>| -> Element<'_, Message> { + let create_tab_button = |tab: Tab, label: &str, description: &str, connected: bool| -> Element<'_, Message> { let label = label.to_string(); - let description = description.map(|d| d.to_string()); let is_selected = self.selected_tab == tab; - let mut col = column![text(label).size(18)]; - if let Some(desc) = description { - col = col.push(text(desc).size(12)); - } + let col = column![ + text(label).size(16), + text( + if connected { + format!("Connected - {}", description) + } else { + format!("{}", description) + } + ).size(12) + ]; let content = container(col) - .padding(10); + .padding(8); let style = move |theme: &Theme, _status| { if is_selected { let mut style = Style::default() .with_background(theme.palette().primary); let mut border = Border::default(); border.color = theme.palette().text; - style.border = border.rounded(20); + style.border = border.rounded(12); style } else { let mut style = Style::default() .with_background(theme.palette().primary.scale_alpha(0.1)); let mut border = Border::default(); border.color = theme.palette().primary.scale_alpha(0.1); - style.border = border.rounded(10); + style.border = border.rounded(8); style.text_color = theme.palette().text; style } @@ -214,6 +307,38 @@ impl App { .into() }; + let create_settings_button = || -> Element<'_, Message> { + let label = "Settings".to_string(); + let is_selected = self.selected_tab == Tab::Settings; + let col = column![text(label).size(16)]; + let content = container(col) + .padding(8); + let style = move |theme: &Theme, _status| { + if is_selected { + let mut style = Style::default() + .with_background(theme.palette().primary); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(12); + style + } else { + let mut style = Style::default() + .with_background(theme.palette().primary.scale_alpha(0.1)); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.1); + style.border = border.rounded(8); + style.text_color = theme.palette().text; + style + } + }; + button(content) + .style(style) + .padding(5) + .on_press(Message::SelectTab(Tab::Settings)) + .width(Length::Fill) + .into() + }; + let mut devices = column!().spacing(4); let mut devices_vec: Vec<(String, DeviceData)> = devices_list.clone().into_iter().collect(); devices_vec.sort_by(|a, b| a.1.name.cmp(&b.1.name)); @@ -222,12 +347,13 @@ impl App { let tab_button = create_tab_button( Tab::Device(mac.clone()), &name, - Some(&mac) + &mac, + self.bluetooth_state.connected_devices.contains(&mac) ); devices = devices.push(tab_button); } - let settings = create_tab_button(Tab::Settings, "Settings", None); + let settings = create_settings_button(); let content = column![ devices, @@ -522,8 +648,15 @@ impl App { } } -async fn wait_for_message(rx: Arc>>) { - debug!("Waiting for message to open main window..."); - let mut guard = rx.lock().await; - let _ = guard.recv().await; +async fn wait_for_message( + ui_rx: Arc>>, +) -> Message { + let mut rx = ui_rx.lock().await; + match rx.recv().await { + Some(msg) => Message::UIMessage(msg), + None => { + error!("UI message channel closed"); + Message::UIMessage(UIMessage::NoOp) + } + } } \ No newline at end of file