From 798498b45b09e1d9615553646c99aa78d38674a6 Mon Sep 17 00:00:00 2001 From: Debarka Kundu Date: Thu, 7 May 2026 13:36:58 +0530 Subject: [PATCH 1/8] refactor: wrap device views in scrollable containers and update height constraints to shrink --- linux-rust/src/ui/airpods.rs | 2 +- linux-rust/src/ui/nothing.rs | 2 +- linux-rust/src/ui/window.rs | 20 +++++++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 9335b5e05..c1a757392 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -520,7 +520,7 @@ pub fn airpods_view<'a>( ]) .padding(20) .center_x(Length::Fill) - .height(Length::Fill) + .height(Length::Shrink) } fn run_async_in_thread(fut: F) diff --git a/linux-rust/src/ui/nothing.rs b/linux-rust/src/ui/nothing.rs index d683f0b16..19295ba9e 100644 --- a/linux-rust/src/ui/nothing.rs +++ b/linux-rust/src/ui/nothing.rs @@ -175,7 +175,7 @@ pub fn nothing_view<'a>( ]) .padding(20) .center_x(Length::Fill) - .height(Length::Fill) + .height(Length::Shrink) } fn run_async_in_thread(fut: F) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..47a2d39a0 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -871,12 +871,16 @@ impl App { match state { DeviceState::AirPods(state) => { device_managers.get(id).and_then(|managers| { - managers.get_aacp().map(|aacp_manager| airpods_view( - id, - &devices_list, - state, - aacp_manager.clone() - )) + managers.get_aacp().map(|aacp_manager| { + let view = airpods_view( + id, + &devices_list, + state, + aacp_manager.clone(), + ); + container(scrollable(view).height(Length::Fill)) + .height(Length::Fill) + }) }) } _ => None, @@ -893,7 +897,9 @@ impl App { if let Some(DeviceState::Nothing(state)) = device_state { if let Some(device_managers) = device_managers.get(id) { if let Some(att_manager) = device_managers.get_att() { - nothing_view(id, &devices_list, state, att_manager.clone()) + let view = nothing_view(id, &devices_list, state, att_manager.clone()); + container(scrollable(view).height(Length::Fill)) + .height(Length::Fill) } else { error!("No ATT manager found for Nothing device {}", id); container( From 0838408074044636e3fe13ac1e65411c69f8551c Mon Sep 17 00:00:00 2001 From: Debarka Kundu Date: Tue, 12 May 2026 13:09:41 +0530 Subject: [PATCH 2/8] refactor: move scrollable wrapping into view functions Addresses maintainer feedback from PR #586 review: - Move scrollable() into airpods_view() and nothing_view() so each view owns its own scroll behavior - Revert window.rs to call views directly without external wrapping - Fix indentation in the Nothing branch of window.rs - Views now fill available space with height(Length::Fill) and scroll internally when content exceeds viewport --- linux-rust/src/ui/airpods.rs | 10 ++++++---- linux-rust/src/ui/nothing.rs | 10 ++++++---- linux-rust/src/ui/window.rs | 20 +++++++------------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index c1a757392..e1e0444c0 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -5,7 +5,7 @@ use iced::overlay::menu; use iced::widget::button::Style; use iced::widget::rule::FillMode; use iced::widget::{ - Space, button, column, combo_box, container, row, rule, text, text_input, toggler, + Space, button, column, combo_box, container, row, rule, scrollable, text, text_input, toggler, }; use iced::{Background, Border, Center, Color, Length, Padding, Theme}; use log::error; @@ -507,7 +507,7 @@ pub fn airpods_view<'a>( } } - container(column![ + let content = container(column![ rename_input, Space::new().height(Length::from(20)), listening_mode, @@ -519,8 +519,10 @@ pub fn airpods_view<'a>( information_col ]) .padding(20) - .center_x(Length::Fill) - .height(Length::Shrink) + .center_x(Length::Fill); + + container(scrollable(content).height(Length::Fill)) + .height(Length::Fill) } fn run_async_in_thread(fut: F) diff --git a/linux-rust/src/ui/nothing.rs b/linux-rust/src/ui/nothing.rs index 19295ba9e..dad661061 100644 --- a/linux-rust/src/ui/nothing.rs +++ b/linux-rust/src/ui/nothing.rs @@ -5,7 +5,7 @@ use iced::border::Radius; use iced::overlay::menu; use iced::widget::combo_box; use iced::widget::text_input; -use iced::widget::{Space, column, container, row, text}; +use iced::widget::{Space, column, container, row, scrollable, text}; use iced::{Background, Border, Length, Theme}; use std::collections::HashMap; use std::sync::Arc; @@ -158,7 +158,7 @@ pub fn nothing_view<'a>( style }); - container(column![ + let content = container(column![ noise_control_mode, Space::new().height(Length::from(20)), container(information_col) @@ -174,8 +174,10 @@ pub fn nothing_view<'a>( .padding(20) ]) .padding(20) - .center_x(Length::Fill) - .height(Length::Shrink) + .center_x(Length::Fill); + + container(scrollable(content).height(Length::Fill)) + .height(Length::Fill) } fn run_async_in_thread(fut: F) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 47a2d39a0..c93ca947d 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -871,16 +871,12 @@ impl App { match state { DeviceState::AirPods(state) => { device_managers.get(id).and_then(|managers| { - managers.get_aacp().map(|aacp_manager| { - let view = airpods_view( - id, - &devices_list, - state, - aacp_manager.clone(), - ); - container(scrollable(view).height(Length::Fill)) - .height(Length::Fill) - }) + managers.get_aacp().map(|aacp_manager| airpods_view( + id, + &devices_list, + state, + aacp_manager.clone(), + )) }) } _ => None, @@ -897,9 +893,7 @@ impl App { if let Some(DeviceState::Nothing(state)) = device_state { if let Some(device_managers) = device_managers.get(id) { if let Some(att_manager) = device_managers.get_att() { - let view = nothing_view(id, &devices_list, state, att_manager.clone()); - container(scrollable(view).height(Length::Fill)) - .height(Length::Fill) + nothing_view(id, &devices_list, state, att_manager.clone()) } else { error!("No ATT manager found for Nothing device {}", id); container( From 22111237cd0dc0c3e8765a435fc9311b509a01cf Mon Sep 17 00:00:00 2001 From: Debarka Kundu Date: Mon, 18 May 2026 22:30:54 +0530 Subject: [PATCH 3/8] feat: collapsible device info with masked serial numbers --- linux-rust/src/ui/airpods.rs | 287 ++++++++++++++++++++--------------- linux-rust/src/ui/nothing.rs | 84 +++++++--- linux-rust/src/ui/window.rs | 21 ++- 3 files changed, 243 insertions(+), 149 deletions(-) diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index e1e0444c0..4993f6412 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -22,6 +22,8 @@ pub fn airpods_view<'a>( devices_list: &HashMap, state: &'a AirPodsState, aacp_manager: Arc, + show_serials: bool, + show_device_info: bool, // att_manager: Arc ) -> iced::widget::Container<'a, Message> { let mac = mac.to_string(); @@ -365,140 +367,175 @@ pub fn airpods_view<'a>( let mut information_col = column![]; if let Some(device) = devices_list.get(mac_information.as_str()) { if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information { - let info_rows = column![ + let chevron = if show_device_info { "\u{25be}" } else { "\u{25b8}" }; + let header = button( row![ - text("Model Number").size(16).style(|theme: &Theme| { + text(format!("{} Device Information", chevron)).size(18).style(|theme: &Theme| { let mut style = text::Style::default(); - style.color = Some(theme.palette().text); + style.color = Some(theme.palette().primary); style }), - Space::new().width(Length::Fill), - text(airpods_info.model_number.clone()).size(16) - ], - row![ - text("Manufacturer").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - text(airpods_info.manufacturer.clone()).size(16) - ], - row![ - text("Serial Number").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - button(text(airpods_info.serial_number.clone()).size(16)) - .style(|theme: &Theme, _status| { - let mut style = Style::default(); - style.text_color = theme.palette().text; - style.background = Some(Background::Color(Color::TRANSPARENT)); + ] + .align_y(iced::Alignment::Center) + ) + .style(|_theme: &Theme, _status| { + let mut style = Style::default(); + style.background = Some(Background::Color(Color::TRANSPARENT)); + style.text_color = Color::TRANSPARENT; + style + }) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .on_press(Message::ToggleDeviceInfo); + + if show_device_info { + let serial_display = |serial: String| -> String { + if show_serials { serial } else { "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string() } + }; + let eye_icon = if show_serials { "\u{1f441}" } else { "\u{25c9}" }; + + let info_rows = column![ + row![ + text("Model Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); style - }) - .padding(0) - .on_press(Message::CopyToClipboard(airpods_info.serial_number.clone())) - ], - row![ - text("Left Serial Number").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - button(text(airpods_info.left_serial_number.clone()).size(16)) - .style(|theme: &Theme, _status| { - let mut style = Style::default(); - style.text_color = theme.palette().text; - style.background = Some(Background::Color(Color::TRANSPARENT)); + }), + Space::new().width(Length::Fill), + text(airpods_info.model_number.clone()).size(16) + ], + row![ + text("Manufacturer").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); style - }) - .padding(0) - .on_press(Message::CopyToClipboard( - airpods_info.left_serial_number.clone() - )) - ], - row![ - text("Right Serial Number").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - button(text(airpods_info.right_serial_number.clone()).size(16)) - .style(|theme: &Theme, _status| { - let mut style = Style::default(); - style.text_color = theme.palette().text; - style.background = Some(Background::Color(Color::TRANSPARENT)); + }), + Space::new().width(Length::Fill), + text(airpods_info.manufacturer.clone()).size(16) + ], + row![ + text("Serial Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); style - }) - .padding(0) - .on_press(Message::CopyToClipboard( - airpods_info.right_serial_number.clone() - )) - ], - row![ - text("Version 1").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - text(airpods_info.version1.clone()).size(16) - ], - row![ - text("Version 2").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - text(airpods_info.version2.clone()).size(16) - ], - row![ - text("Version 3").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - text(airpods_info.version3.clone()).size(16) + }), + Space::new().width(Length::Fill), + button( + row![ + text(serial_display(airpods_info.serial_number.clone())).size(16), + text(eye_icon).size(14), + ].spacing(6).align_y(iced::Alignment::Center) + ) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + }) + .padding(0) + .on_press(Message::ToggleSerialVisibility) + ], + row![ + text("Left Serial Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::new().width(Length::Fill), + button( + row![ + text(serial_display(airpods_info.left_serial_number.clone())).size(16), + text(eye_icon).size(14), + ].spacing(6).align_y(iced::Alignment::Center) + ) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + }) + .padding(0) + .on_press(Message::ToggleSerialVisibility) + ], + row![ + text("Right Serial Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::new().width(Length::Fill), + button( + row![ + text(serial_display(airpods_info.right_serial_number.clone())).size(16), + text(eye_icon).size(14), + ].spacing(6).align_y(iced::Alignment::Center) + ) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + }) + .padding(0) + .on_press(Message::ToggleSerialVisibility) + ], + row![ + text("Version 1").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::new().width(Length::Fill), + text(airpods_info.version1.clone()).size(16) + ], + row![ + text("Version 2").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::new().width(Length::Fill), + text(airpods_info.version2.clone()).size(16) + ], + row![ + text("Version 3").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::new().width(Length::Fill), + text(airpods_info.version3.clone()).size(16) + ] ] - ] - .spacing(4) - .padding(8); + .spacing(4) + .padding(8); - information_col = column![ - container(text("Device Information").size(18).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().primary); - style - })) - .padding(Padding { - top: 5.0, - bottom: 5.0, - left: 18.0, - right: 18.0, - }), - container(info_rows) - .padding(Padding { - top: 5.0, - bottom: 5.0, - left: 10.0, - right: 10.0, - }) - .style(|theme: &Theme| { - let mut style = container::Style::default(); - style.background = - Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); - let mut border = Border::default(); - border.color = theme.palette().primary.scale_alpha(0.5); - style.border = border.rounded(16); - style - }) - ]; + information_col = column![ + header, + container(info_rows) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = + Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }) + ]; + } else { + information_col = column![header]; + } } else { error!( "Expected AirPodsInformation for device {}, got something else", diff --git a/linux-rust/src/ui/nothing.rs b/linux-rust/src/ui/nothing.rs index dad661061..907930b42 100644 --- a/linux-rust/src/ui/nothing.rs +++ b/linux-rust/src/ui/nothing.rs @@ -5,7 +5,7 @@ use iced::border::Radius; use iced::overlay::menu; use iced::widget::combo_box; use iced::widget::text_input; -use iced::widget::{Space, column, container, row, scrollable, text}; +use iced::widget::{Space, button, column, container, row, scrollable, text}; use iced::{Background, Border, Length, Theme}; use std::collections::HashMap; use std::sync::Arc; @@ -17,37 +17,75 @@ pub fn nothing_view<'a>( devices_list: &HashMap, state: &'a NothingState, att_manager: Arc, + show_serials: bool, + show_device_info: bool, ) -> iced::widget::Container<'a, Message> { let mut information_col = iced::widget::column![]; let mac = mac.to_string(); if let Some(device) = devices_list.get(mac.as_str()) && let Some(DeviceInformation::Nothing(ref nothing_info)) = device.information { - information_col = information_col - .push(text("Device Information").size(18).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().primary); - style - })) - .push(Space::new().height(iced::Length::from(10))) - .push(iced::widget::row![ - text("Serial Number").size(16).style(|theme: &Theme| { + let chevron = if show_device_info { "\u{25be}" } else { "\u{25b8}" }; + let header = button( + row![ + text(format!("{} Device Information", chevron)).size(18).style(|theme: &Theme| { let mut style = text::Style::default(); - style.color = Some(theme.palette().text); + style.color = Some(theme.palette().primary); style }), - Space::new().width(Length::Fill), - text(nothing_info.serial_number.clone()).size(16) - ]) - .push(iced::widget::row![ - text("Firmware Version").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - text(nothing_info.firmware_version.clone()).size(16) - ]); + ] + .align_y(iced::Alignment::Center) + ) + .style(|_theme: &Theme, _status| { + let mut style = button::Style::default(); + style.background = Some(Background::Color(iced::Color::TRANSPARENT)); + style.text_color = iced::Color::TRANSPARENT; + style + }) + .padding(0) + .on_press(Message::ToggleDeviceInfo); + + if show_device_info { + let eye_icon = if show_serials { "\u{1f441}" } else { "\u{25c9}" }; + let serial_text = if show_serials { nothing_info.serial_number.clone() } else { "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string() }; + + information_col = information_col + .push(header) + .push(Space::new().height(iced::Length::from(10))) + .push(iced::widget::row![ + text("Serial Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::new().width(Length::Fill), + button( + row![ + text(serial_text).size(16), + text(eye_icon).size(14), + ].spacing(6).align_y(iced::Alignment::Center) + ) + .style(|theme: &Theme, _status| { + let mut style = button::Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(iced::Color::TRANSPARENT)); + style + }) + .padding(0) + .on_press(Message::ToggleSerialVisibility) + ]) + .push(iced::widget::row![ + text("Firmware Version").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::new().width(Length::Fill), + text(nothing_info.firmware_version.clone()).size(16) + ]); + } else { + information_col = information_col.push(header); + } } let noise_control_mode = container( diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index c93ca947d..bf6757419 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -76,6 +76,8 @@ pub struct App { selected_device_type: Option, tray_text_mode: bool, stem_control: bool, + show_serials: bool, + show_device_info: bool, } pub struct BluetoothState { @@ -108,6 +110,8 @@ pub enum Message { StateChanged(String, DeviceState), TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy StemControlChanged(bool), + ToggleSerialVisibility, + ToggleDeviceInfo, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -217,6 +221,8 @@ impl App { device_managers, tray_text_mode, stem_control, + show_serials: false, + show_device_info: false, }, Task::batch(vec![open_task, wait_task]), ) @@ -656,6 +662,17 @@ impl App { std::fs::write(app_settings_path, settings.to_string()).ok(); Task::none() } + Message::ToggleSerialVisibility => { + self.show_serials = !self.show_serials; + Task::none() + } + Message::ToggleDeviceInfo => { + self.show_device_info = !self.show_device_info; + if !self.show_device_info { + self.show_serials = false; + } + Task::none() + } } } @@ -876,6 +893,8 @@ impl App { &devices_list, state, aacp_manager.clone(), + self.show_serials, + self.show_device_info, )) }) } @@ -893,7 +912,7 @@ impl App { if let Some(DeviceState::Nothing(state)) = device_state { if let Some(device_managers) = device_managers.get(id) { if let Some(att_manager) = device_managers.get_att() { - nothing_view(id, &devices_list, state, att_manager.clone()) + nothing_view(id, &devices_list, state, att_manager.clone(), self.show_serials, self.show_device_info) } else { error!("No ATT manager found for Nothing device {}", id); container( From 91c3940c256970581fb73b3fab0b67c03d742008 Mon Sep 17 00:00:00 2001 From: Debarka Kundu Date: Tue, 19 May 2026 21:32:31 +0530 Subject: [PATCH 4/8] feat: redesign disconnected device state with connect button --- linux-rust/src/bluetooth/aacp.rs | 2 +- linux-rust/src/ui/window.rs | 94 +++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index bed6ca853..8913e6288 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -5,7 +5,7 @@ use bluer::{ Address, AddressType, Error, Result, l2cap::{SeqPacket, Socket, SocketAddr}, }; -use log::{debug, error, info}; +use log::{debug, error, info};r use serde::{Deserialize, Serialize}; use serde_json; use std::collections::HashMap; diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index bf6757419..ed5db2df8 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -19,9 +19,9 @@ use iced::widget::{ Space, button, column, combo_box, container, pane_grid, row, rule, scrollable, text, text_input, toggler }; -use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings, Program}; +use iced::{Background, Border, Center, Color, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings, Program}; use log::{debug, error}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::sync::mpsc::UnboundedReceiver; @@ -78,6 +78,7 @@ pub struct App { stem_control: bool, show_serials: bool, show_device_info: bool, + connecting_devices: HashSet, } pub struct BluetoothState { @@ -112,6 +113,8 @@ pub enum Message { StemControlChanged(bool), ToggleSerialVisibility, ToggleDeviceInfo, + ConnectDevice(String), + ConnectResult(String, bool), } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -223,6 +226,7 @@ impl App { stem_control, show_serials: false, show_device_info: false, + connecting_devices: HashSet::new(), }, Task::batch(vec![open_task, wait_task]), ) @@ -673,6 +677,25 @@ impl App { } Task::none() } + Message::ConnectDevice(mac) => { + self.connecting_devices.insert(mac.clone()); + Task::perform( + async move { + let output = tokio::process::Command::new("bluetoothctl") + .arg("connect") + .arg(&mac) + .output() + .await; + let success = output.map(|o| o.status.success()).unwrap_or(false); + (mac, success) + }, + |(mac, success)| Message::ConnectResult(mac, success), + ) + } + Message::ConnectResult(mac, _success) => { + self.connecting_devices.remove(&mac); + Task::none() + } } } @@ -881,6 +904,62 @@ impl App { let device_type = devices_list.get(id).map(|d| d.type_.clone()); let device_state = self.device_states.get(id); debug!("Rendering device view for {}: type={:?}, state={:?}", id, device_type, device_state); + let is_connected = self.bluetooth_state.connected_devices.contains(id); + let is_connecting = self.connecting_devices.contains(id); + let device_name = devices_list.get(id).map(|d| d.name.clone()).unwrap_or_else(|| id.clone()); + + if !is_connected && !is_connecting { + let id_clone = id.clone(); + container( + column![ + text("\u{1F3A7}").size(64), + Space::new().height(Length::from(16)), + text(device_name).size(22), + Space::new().height(Length::from(8)), + text("Not Connected").size(14).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.5)); + style + }), + Space::new().height(Length::from(24)), + button( + container( + text("Connect").size(15) + ) + .padding(Padding { top: 8.0, bottom: 8.0, left: 24.0, right: 24.0 }) + ) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.background = Some(Background::Color(theme.palette().primary)); + style.text_color = Color::WHITE; + style.border = Border::default().rounded(10); + style + }) + .padding(0) + .on_press(Message::ConnectDevice(id_clone)) + ] + .align_x(iced::Alignment::Center) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + } else if is_connecting { + container( + column![ + text("\u{1F3A7}").size(64), + Space::new().height(Length::from(16)), + text(device_name).size(22), + Space::new().height(Length::from(8)), + text("Connecting\u{2026}").size(14).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + }), + ] + .align_x(iced::Alignment::Center) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + } else { match device_type { Some(DeviceType::AirPods) => { @@ -902,7 +981,7 @@ impl App { } }).unwrap_or_else(|| { container( - text("Required managers or state not available for this AirPods device").size(16) + text("Loading device...").size(16) ) .center_x(Length::Fill) .center_y(Length::Fill) @@ -914,24 +993,22 @@ impl App { if let Some(att_manager) = device_managers.get_att() { nothing_view(id, &devices_list, state, att_manager.clone(), self.show_serials, self.show_device_info) } else { - error!("No ATT manager found for Nothing device {}", id); container( - text("No valid ATT manager found for this Nothing device").size(16) + text("Loading device...").size(16) ) .center_x(Length::Fill) .center_y(Length::Fill) } } else { - error!("No manager found for Nothing device {}", id); container( - text("No manager found for this Nothing device").size(16) + text("Loading device...").size(16) ) .center_x(Length::Fill) .center_y(Length::Fill) } } else { container( - text("No state available for this Nothing device").size(16) + text("Loading device...").size(16) ) .center_x(Length::Fill) .center_y(Length::Fill) @@ -943,6 +1020,7 @@ impl App { .center_y(Length::Fill) } } + } } } Tab::Settings => { From a5818cea0354177f548b6f9c7c262ae8f8a19137 Mon Sep 17 00:00:00 2001 From: Debarka Kundu Date: Wed, 20 May 2026 03:26:11 +0530 Subject: [PATCH 5/8] feat: show friendly AirPods model name alongside raw model number --- linux-rust/src/bluetooth/aacp.rs | 2 +- linux-rust/src/devices/airpods.rs | 21 +++++++++++++++++++++ linux-rust/src/ui/airpods.rs | 5 ++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 8913e6288..bed6ca853 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -5,7 +5,7 @@ use bluer::{ Address, AddressType, Error, Result, l2cap::{SeqPacket, Socket, SocketAddr}, }; -use log::{debug, error, info};r +use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use serde_json; use std::collections::HashMap; diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs index f0e876cf0..b6845a2aa 100644 --- a/linux-rust/src/devices/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -412,3 +412,24 @@ pub struct AirPodsInformation { pub version3: String, pub le_keys: AirPodsLEKeys, } + +impl AirPodsInformation { + /// Returns a friendly model name for the raw model number, if known. + /// Mapping ported from linux/enums.h parseModelNumber(). + /// Source: https://support.apple.com/en-us/109525 + pub fn friendly_model_name(&self) -> Option<&'static str> { + match self.model_number.as_str() { + "A1523" | "A1722" => Some("AirPods 1st Gen"), + "A2032" | "A2031" => Some("AirPods 2nd Gen"), + "A2564" | "A2565" => Some("AirPods 3rd Gen"), + "A3053" | "A3050" | "A3054" => Some("AirPods 4"), + "A3055" | "A3056" | "A3057" => Some("AirPods 4 ANC"), + "A2083" | "A2084" => Some("AirPods Pro"), + "A2698" | "A2699" | "A2931" => Some("AirPods Pro 2"), + "A3047" | "A3048" | "A3049" => Some("AirPods Pro 2 USB-C"), + "A2096" => Some("AirPods Max"), + "A3184" => Some("AirPods Max USB-C"), + _ => None, + } + } +} diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 4993f6412..f45a5c3aa 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -406,7 +406,10 @@ pub fn airpods_view<'a>( style }), Space::new().width(Length::Fill), - text(airpods_info.model_number.clone()).size(16) + text(match airpods_info.friendly_model_name() { + Some(name) => format!("{} ({})", airpods_info.model_number, name), + None => airpods_info.model_number.clone(), + }).size(16) ], row![ text("Manufacturer").size(16).style(|theme: &Theme| { From 9f12f077a603f30e8ae31d86605df026462d2698 Mon Sep 17 00:00:00 2001 From: Debarka Kundu Date: Wed, 20 May 2026 03:44:01 +0530 Subject: [PATCH 6/8] feat: replace listening mode dropdown with segmented icon buttons and move off mode toggle to settings --- linux-rust/assets/icons/adaptive.png | Bin 0 -> 4255 bytes .../assets/icons/noise_cancellation.png | Bin 0 -> 8681 bytes linux-rust/assets/icons/transparency.png | Bin 0 -> 9219 bytes linux-rust/src/devices/enums.rs | 3 +- linux-rust/src/ui/airpods.rs | 327 ++++++++++-------- linux-rust/src/ui/window.rs | 112 +++--- 6 files changed, 263 insertions(+), 179 deletions(-) create mode 100644 linux-rust/assets/icons/adaptive.png create mode 100644 linux-rust/assets/icons/noise_cancellation.png create mode 100644 linux-rust/assets/icons/transparency.png diff --git a/linux-rust/assets/icons/adaptive.png b/linux-rust/assets/icons/adaptive.png new file mode 100644 index 0000000000000000000000000000000000000000..b0863357c3604d7492db446629c98f98113c009f GIT binary patch literal 4255 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

!FD2;wH$F>;lYQBYj-!&jCn9Iez`~gb9u3O*c^b zMP9=uX6grIj@;3=Ji$8SlZ>Dd?^}nMaM_>87y6!3`JoG!$4cGn`rUs+G!?xvXkn?+ zmLOO>vdw+{{0`zG6SgL8;7&1z0mDJ(oo?LaTyM1(6~XIs0i))bZ(aDU25dhCz)MBe zntgrWv8M~*K3bkyRowmbAbOqQK&9-GQTdA*#4<=C6{U3d015%KrG0~@s^yC=;cfe% z@FjwE#}K;_ubRr&crd^JxvxT)5zs0%(tj{hUyTrdbnMibj{=`NG3GZzPhFD*CTO^~ zl2V1aS^f;+95_-lJQAC;rUUBT2Amvhr~-$#D=;;H6n&O!vzbvtnUCJ@7B(6v=l=>LZ|-@ zXOBbtJ7D5;2-dp67xC6{Gi4y%=Y!Ec!zvX1!)}tBGYzA?<)CpB7&o+5saVT|TK;F4 z7+x)9RDbR`VndLd%9??HC!*rB%d|&kI(p)7l(rceA6C~G&8>&1K?H#rPobyU zz^39z1a_c4X1i%XH{zrs?vx#UjUV6x_G62q8stzg(_b#?$kT6JMGhTO_>yv3rs zwXX|zcAN75+}!9Z&{_0r(HshR+9up{a~&B?cH!(6n|tVH;(oZQCeThvfgato{+G=G zg+iqdawmYk;n|w878-sBWtPUcs+X)KbfO8nocH5mMESjobhfz(8;RXBA_i_Jr zr|6u)9QBL5YEQ~)XF<+HEDYCwFjuw>gq4lve^OsKIuL=je}ZAr*7I##fx@^hU(-0# zeG)588=j^hyE`G9dvbBC9sS63Uki~H+{N}o#RK^e@T;ZHNvBgel4pA%I(C?@N zqM_ja0cK`tE7aF&3at)qJWf36zPR>aywu#O7-iigaD7b)zPES%ONsfD>bz*P6Xd8H zfPhu|`E*}&RFR=3NUOSa(p)LoaRgzZ@(tF;i@6GD4ASx=pwmDk;F_Rg-)_pG8r<(n z=G@Naf|UPZdk)h7%f8@yDP-XzbG5{7lyARu1{e5#1@Gvln{UPf*1hq#5Jld?oOY=+5g89%0Fdh_O%oW zyivvq{5wJ-SkE@t-6a^M=@y6rHjt8nlIk@DC16=9XlN=cXsRg6Dkx|wC|E5pJo{e< deEr=$(P97h0dY)+HgEuBq-Ta`y#3(W{{XG@yO;m~ literal 0 HcmV?d00001 diff --git a/linux-rust/assets/icons/transparency.png b/linux-rust/assets/icons/transparency.png new file mode 100644 index 0000000000000000000000000000000000000000..b03b74cbff6fd2ecc19e6cc2c15a97ea1ba83c75 GIT binary patch literal 9219 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 LPZwQ-vWoa0M;29O literal 0 HcmV?d00001 diff --git a/linux-rust/src/devices/enums.rs b/linux-rust/src/devices/enums.rs index 5768d1802..081a43ba9 100644 --- a/linux-rust/src/devices/enums.rs +++ b/linux-rust/src/devices/enums.rs @@ -53,14 +53,13 @@ impl Display for DeviceState { pub struct AirPodsState { pub device_name: String, pub noise_control_mode: AirPodsNoiseControlMode, - pub noise_control_state: combo_box::State, pub conversation_awareness_enabled: bool, pub personalized_volume_enabled: bool, pub allow_off_mode: bool, pub battery: Vec, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum AirPodsNoiseControlMode { Off, NoiseCancellation, diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index f45a5c3aa..9f106042f 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -1,11 +1,11 @@ use crate::bluetooth::aacp::{AACPManager, ControlCommandIdentifiers}; use iced::Alignment::End; use iced::border::Radius; -use iced::overlay::menu; use iced::widget::button::Style; +use iced::widget::image; use iced::widget::rule::FillMode; use iced::widget::{ - Space, button, column, combo_box, container, row, rule, scrollable, text, text_input, toggler, + Space, button, column, container, row, rule, scrollable, text, text_input, toggler, }; use iced::{Background, Border, Center, Color, Length, Padding, Theme}; use log::error; @@ -14,9 +14,116 @@ use std::sync::Arc; use std::thread; use tokio::runtime::Runtime; // use crate::bluetooth::att::ATTManager; -use crate::devices::enums::{AirPodsState, DeviceData, DeviceInformation, DeviceState}; +use crate::devices::enums::{ + AirPodsNoiseControlMode, AirPodsState, DeviceData, DeviceInformation, DeviceState, +}; use crate::ui::window::Message; +// Embed the listening mode icons at compile time from the Android assets +const ICON_NOISE_CANCELLATION: &[u8] = + include_bytes!("../../assets/icons/noise_cancellation.png"); +const ICON_TRANSPARENCY: &[u8] = include_bytes!("../../assets/icons/transparency.png"); +const ICON_ADAPTIVE: &[u8] = include_bytes!("../../assets/icons/adaptive.png"); + +/// Build a single segmented button for a listening mode. +fn listening_mode_button<'a>( + mode: AirPodsNoiseControlMode, + is_selected: bool, + icon_bytes: Option<&'static [u8]>, + label: &'a str, + mac: String, + state: AirPodsState, + aacp_manager: Arc, +) -> iced::Element<'a, Message> { + let icon_element: iced::Element<'a, Message> = if let Some(bytes) = icon_bytes { + image(image::Handle::from_bytes(bytes)) + .width(28) + .height(28) + .into() + } else { + // "Off" mode uses a unicode power symbol instead of a PNG icon + text("\u{23FB}") + .size(22) + .align_x(Center) + .style(move |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(if is_selected { + theme.palette().primary + } else { + theme.palette().text.scale_alpha(0.6) + }); + style + }) + .into() + }; + + let label_text = text(label).size(11).align_x(Center).style( + move |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(if is_selected { + theme.palette().primary + } else { + theme.palette().text.scale_alpha(0.7) + }); + style + }, + ); + + let content = column![icon_element, label_text] + .spacing(4) + .align_x(Center) + .width(Length::Fill); + + let mode_clone = mode.clone(); + button(content) + .padding(Padding { + top: 10.0, + bottom: 8.0, + left: 4.0, + right: 4.0, + }) + .width(Length::Fill) + .style(move |theme: &Theme, _status| { + let mut style = Style::default(); + if is_selected { + style.background = + Some(Background::Color(theme.palette().primary.scale_alpha(0.15))); + style.border = Border { + width: 1.5, + color: theme.palette().primary.scale_alpha(0.5), + radius: Radius::from(12.0), + }; + } else { + style.background = Some(Background::Color(Color::TRANSPARENT)); + style.border = Border { + width: 1.0, + color: theme.palette().text.scale_alpha(0.1), + radius: Radius::from(12.0), + }; + } + style.text_color = theme.palette().text; + style + }) + .on_press({ + let aacp_manager = aacp_manager.clone(); + let mode_byte = mode_clone.to_byte(); + let selected_mode = mode.clone(); + run_async_in_thread(async move { + aacp_manager + .send_control_command( + ControlCommandIdentifiers::ListeningMode, + &[mode_byte], + ) + .await + .expect("Failed to send Noise Control Mode command"); + }); + let mut new_state = state.clone(); + new_state.noise_control_mode = selected_mode; + Message::StateChanged(mac, DeviceState::AirPods(new_state)) + }) + .into() +} + pub fn airpods_view<'a>( mac: &'a str, devices_list: &HashMap, @@ -24,6 +131,7 @@ pub fn airpods_view<'a>( aacp_manager: Arc, show_serials: bool, show_device_info: bool, + show_off_listening_mode: bool, // att_manager: Arc ) -> iced::widget::Container<'a, Message> { let mac = mac.to_string(); @@ -94,92 +202,92 @@ pub fn airpods_view<'a>( style }); + // --- Segmented listening mode control --- + let mut mode_buttons: Vec> = Vec::new(); + + // Conditionally include "Off" based on allow_off_mode (from device) AND show_off_listening_mode (from settings) + if state.allow_off_mode && show_off_listening_mode { + mode_buttons.push(listening_mode_button( + AirPodsNoiseControlMode::Off, + state.noise_control_mode == AirPodsNoiseControlMode::Off, + None, + "Off", + mac.clone(), + state.clone(), + aacp_manager.clone(), + )); + } + + mode_buttons.push(listening_mode_button( + AirPodsNoiseControlMode::NoiseCancellation, + state.noise_control_mode == AirPodsNoiseControlMode::NoiseCancellation, + Some(ICON_NOISE_CANCELLATION), + "Noise Cancel", + mac.clone(), + state.clone(), + aacp_manager.clone(), + )); + + mode_buttons.push(listening_mode_button( + AirPodsNoiseControlMode::Transparency, + state.noise_control_mode == AirPodsNoiseControlMode::Transparency, + Some(ICON_TRANSPARENCY), + "Transparency", + mac.clone(), + state.clone(), + aacp_manager.clone(), + )); + + mode_buttons.push(listening_mode_button( + AirPodsNoiseControlMode::Adaptive, + state.noise_control_mode == AirPodsNoiseControlMode::Adaptive, + Some(ICON_ADAPTIVE), + "Adaptive", + mac.clone(), + state.clone(), + aacp_manager.clone(), + )); + let listening_mode = container( - row![ - text("Listening Mode").size(16).style(|theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - }), - Space::new().width(Length::Fill), - { - let state_clone = state.clone(); - let mac = mac.clone(); - // this combo_box doesn't go really well with the design, but I am not writing my own dropdown menu for this - combo_box( - &state.noise_control_state, - "Select Listening Mode", - Some(&state.noise_control_mode.clone()), - { - let aacp_manager = aacp_manager.clone(); - move |selected_mode| { - let aacp_manager = aacp_manager.clone(); - let selected_mode_c = selected_mode.clone(); - run_async_in_thread(async move { - aacp_manager - .send_control_command( - ControlCommandIdentifiers::ListeningMode, - &[selected_mode_c.to_byte()], - ) - .await - .expect("Failed to send Noise Control Mode command"); - }); - let mut state = state_clone.clone(); - state.noise_control_mode = selected_mode.clone(); - Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) - } - }, - ) - .width(Length::from(200)) - .input_style(|theme: &Theme, _status| text_input::Style { - background: Background::Color(theme.palette().primary.scale_alpha(0.2)), - border: Border { - width: 1.0, - color: theme.palette().text.scale_alpha(0.3), - radius: Radius::from(4.0), - }, - icon: Default::default(), - placeholder: theme.palette().text, - value: theme.palette().text, - selection: Default::default(), - }) - .padding(Padding { - top: 5.0, - bottom: 5.0, - left: 10.0, - right: 10.0, - }) - .menu_style(|theme: &Theme| menu::Style { - background: Background::Color(theme.palette().background), - border: Border { - width: 1.0, - color: theme.palette().text, - radius: Radius::from(4.0), - }, - text_color: theme.palette().text, - selected_text_color: theme.palette().text, - selected_background: Background::Color( - theme.palette().primary.scale_alpha(0.3), - ), - shadow: Default::default() + column![ + container( + text("Listening Mode").size(18).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style }) - } + ) + .padding(Padding { + top: 0.0, + bottom: 4.0, + left: 4.0, + right: 4.0, + }), + container( + row(mode_buttons).spacing(6) + ) + .padding(Padding { + top: 4.0, + bottom: 4.0, + left: 4.0, + right: 4.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = + Some(Background::Color(theme.palette().primary.scale_alpha(0.05))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.3); + style.border = border.rounded(16); + style + }) ] - .align_y(Center), ) .padding(Padding { top: 5.0, bottom: 5.0, - left: 18.0, - right: 18.0, - }) - .style(|theme: &Theme| { - let mut style = container::Style::default(); - style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); - let mut border = Border::default(); - border.color = theme.palette().primary.scale_alpha(0.5); - style.border = border.rounded(16); - style + left: 14.0, + right: 14.0, }); let mac_audio = mac.clone(); @@ -311,59 +419,6 @@ pub fn airpods_view<'a>( ) ]; - let off_listening_mode_toggle = { - let aacp_manager_olm = aacp_manager.clone(); - let mac = mac.clone(); - container(row![ - column![ - text("Off Listening Mode").size(16), - text("When this is on, AirPods listening modes will include an Off option. Loud sound levels are not reduced when listening mode is set to Off.").size(12).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text.scale_alpha(0.7)); - style - } - ).width(Length::Fill) - ].width(Length::Fill), - toggler(state.allow_off_mode) - .on_toggle(move |is_enabled| { - let aacp_manager = aacp_manager_olm.clone(); - run_async_in_thread( - async move { - aacp_manager.send_control_command( - ControlCommandIdentifiers::AllowOffOption, - if is_enabled { &[0x01] } else { &[0x02] } - ).await.expect("Failed to send Off Listening Mode command"); - } - ); - let mut state = state.clone(); - state.allow_off_mode = is_enabled; - Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) - }) - .spacing(0) - .size(20) - ] - .align_y(Center) - .spacing(8) - ) - .padding(Padding{ - top: 5.0, - bottom: 5.0, - left: 18.0, - right: 18.0, - }) - .style( - |theme: &Theme| { - let mut style = container::Style::default(); - style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); - let mut border = Border::default(); - border.color = theme.palette().primary.scale_alpha(0.5); - style.border = border.rounded(16); - style - } - ) - }; - let mut information_col = column![]; if let Some(device) = devices_list.get(mac_information.as_str()) { if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information { @@ -554,8 +609,6 @@ pub fn airpods_view<'a>( Space::new().height(Length::from(20)), audio_settings_col, Space::new().height(Length::from(20)), - off_listening_mode_toggle, - Space::new().height(Length::from(20)), information_col ]) .padding(20) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index ed5db2df8..531605dad 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -78,6 +78,7 @@ pub struct App { stem_control: bool, show_serials: bool, show_device_info: bool, + show_off_listening_mode: bool, connecting_devices: HashSet, } @@ -115,6 +116,7 @@ pub enum Message { ToggleDeviceInfo, ConnectDevice(String), ConnectResult(String, bool), + ShowOffListeningModeChanged(bool), } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -173,6 +175,11 @@ impl App { .and_then(|v| v.get("stem_control").cloned()) .and_then(|s| serde_json::from_value(s).ok()) .unwrap_or(false); + let show_off_listening_mode = settings + .clone() + .and_then(|v| v.get("show_off_listening_mode").cloned()) + .and_then(|s| serde_json::from_value(s).ok()) + .unwrap_or(true); let bluetooth_state = BluetoothState::new(); @@ -226,6 +233,7 @@ impl App { stem_control, show_serials: false, show_device_info: false, + show_off_listening_mode, connecting_devices: HashSet::new(), }, Task::batch(vec![open_task, wait_task]), @@ -263,6 +271,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "show_off_listening_mode": self.show_off_listening_mode, }); debug!( "Writing settings to {}: {}", @@ -364,22 +373,7 @@ impl App { None } }).unwrap_or(AirPodsNoiseControlMode::Transparency), - noise_control_state: combo_box::State::new( - { - let mut modes = vec![ - AirPodsNoiseControlMode::Transparency, - AirPodsNoiseControlMode::NoiseCancellation, - AirPodsNoiseControlMode::Adaptive - ]; - if state.control_command_status_list.iter().any(|status| { - status.identifier == ControlCommandIdentifiers::AllowOffOption && - matches!(status.value.as_slice(), [0x01]) - }) { - modes.insert(0, AirPodsNoiseControlMode::Off); - } - modes - } - ), + conversation_awareness_enabled: state.control_command_status_list.iter().any(|status| { status.identifier == ControlCommandIdentifiers::ConversationDetectConfig && matches!(status.value.as_slice(), [0x01]) @@ -502,17 +496,6 @@ impl App { self.device_states.get_mut(&mac) { state.allow_off_mode = is_enabled; - state.noise_control_state = combo_box::State::new({ - let mut modes = vec![ - AirPodsNoiseControlMode::Transparency, - AirPodsNoiseControlMode::NoiseCancellation, - AirPodsNoiseControlMode::Adaptive, - ]; - if is_enabled { - modes.insert(0, AirPodsNoiseControlMode::Off); - } - modes - }); } } _ => { @@ -618,19 +601,9 @@ impl App { devices_list.get(&mac).map(|d| d.type_.clone()) }; if let Some(DeviceType::AirPods) = type_ - && let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) + && let Some(DeviceState::AirPods(_state)) = self.device_states.get_mut(&mac) { - state.noise_control_state = combo_box::State::new({ - let mut modes = vec![ - AirPodsNoiseControlMode::Transparency, - AirPodsNoiseControlMode::NoiseCancellation, - AirPodsNoiseControlMode::Adaptive, - ]; - if state.allow_off_mode { - modes.insert(0, AirPodsNoiseControlMode::Off); - } - modes - }); + // No-op: segmented buttons determine available modes at render time } Task::none() } @@ -641,6 +614,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "show_off_listening_mode": self.show_off_listening_mode, }); debug!( "Writing settings to {}: {}", @@ -657,6 +631,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "show_off_listening_mode": self.show_off_listening_mode, }); debug!( "Writing settings to {}: {}", @@ -677,6 +652,23 @@ impl App { } Task::none() } + Message::ShowOffListeningModeChanged(is_enabled) => { + self.show_off_listening_mode = is_enabled; + let app_settings_path = get_app_settings_path(); + let settings = serde_json::json!({ + "theme": self.selected_theme, + "tray_text_mode": self.tray_text_mode, + "stem_control": self.stem_control, + "show_off_listening_mode": self.show_off_listening_mode, + }); + debug!( + "Writing settings to {}: {}", + app_settings_path.to_str().unwrap(), + settings + ); + std::fs::write(app_settings_path, settings.to_string()).ok(); + Task::none() + } Message::ConnectDevice(mac) => { self.connecting_devices.insert(mac.clone()); Task::perform( @@ -974,6 +966,7 @@ impl App { aacp_manager.clone(), self.show_serials, self.show_device_info, + self.show_off_listening_mode, )) }) } @@ -1209,7 +1202,46 @@ impl App { left: 18.0, right: 18.0, }), - stem_control_toggle + stem_control_toggle, + container( + row![ + column![ + text("Show Off listening mode").size(16), + text("When enabled, an Off option appears in the listening mode controls. Loud sound levels are not reduced when listening mode is set to Off.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill) + ].width(Length::Fill), + toggler(self.show_off_listening_mode) + .on_toggle(move |is_enabled| { + Message::ShowOffListeningModeChanged(is_enabled) + }) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(12) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + .align_y(Center) ] .spacing(12); From 56024152552fad9cadcd279bb037be935459d89e Mon Sep 17 00:00:00 2001 From: Debarka Kundu Date: Wed, 20 May 2026 03:45:03 +0530 Subject: [PATCH 7/8] docs: migrate and update README from main branch --- README.md | 302 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 211 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index c533b170e..10eb54471 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,236 @@ -![LibrePods Banner](/imgs/banner.png) +>[!IMPORTANT] +Development paused due to lack of time until June 2026 (JEE Advanced). PRs and issues might not be responded to until then. -[![XDA Thread](https://img.shields.io/badge/XDA_Forums-Thread-orange)](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/) -[![GitHub release (latest by date)](https://img.shields.io/github/v/release/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/releases/latest) -[![GitHub all releases](https://img.shields.io/github/downloads/kavishdevar/librepods/total)](https://github.com/kavishdevar/librepods/releases) -[![GitHub stars](https://img.shields.io/github/stars/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/stargazers) -[![GitHub issues](https://img.shields.io/github/issues/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/issues) -[![GitHub license](https://img.shields.io/github/license/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/blob/main/LICENSE) -[![GitHub contributors](https://img.shields.io/github/contributors/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/graphs/contributors) +--- + + + + LibrePods + -## What is LibrePods? +

-LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem. +# What is LibrePods? -## Device Compatibility +LibrePods allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms. -| Status | Device | Features | -| ------ | --------------------- | ---------------------------------------------------------- | -| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested | -| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) | -| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | +# Feature availability -Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. +| Feature | Linux | Android | +| ----------------------------------------------------------- | ----- | ------- | +| Changing Listening Mode | ✅ | ✅ | +| Ear detection | ✅ | ✅ | +| Battery status | ✅ | ✅ | +| Renaming AirPods
Note for AndroidOn Android, you need to re-pair your AirPods after renaming them because Android might not use the latest name.
| ✅ | ✅ | +| Loud Sound Reduction | 🔴 | ⚪ | +| Head Gestures | ⛔ | ✅ | +| Conversational Awareness | ✅ | ✅ | +| Automatically connect to AirPods | ✅ | ✅ | +| Hearing Aid | 🔴 | ⚪ | +| Transparency Mode customization | 🔴 | ⚪ | +| Multi-device connectivity (Bluetooth Multipoint; 2 devices only) | ⚪ | ⚪ | +|
Other accessibility configs (click to expand)
  • Press speed
  • Press and Hold duration
  • Noise Cancellation with single AirPod
  • Volume control on swipe
  • Volume swipe speed
| 🔴 | ✅ | +|
Other general configs
  • Press and Hold to cycle between listening modes/invoke digital assistant (invoking digital assistant needs a recent firmware)
  • Configure call controls
  • Personalized volume
  • Loud Sound Reduction (needs VendorID spoofing)
  • Microphone side
  • Pause media when falling asleep (needs a recent firmware)
  • Enable Off listening mode to switch to Off
| 🔴 | ✅ | +| [Head-tracked Spatial Audio](#spatial-audio) | ❓ | ❓ | +| [Heart Rate Monitoring](#heart-rate-monitoring) | ⛔ | 🔴 | +| [Find My](#find-my) | ❓ | ❓ | +| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 | -## Key Features +| Symbol | Meaning | +| ------ | ------------------------------------------------------------------- | +| ✅ | Implemented and works well | +| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk | +| 🔴 | Not implemented yet; planned | +| ⛔ | Will not be implemented | +| ❓ | Unknown | -- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press -- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out -- **Battery Status**: Accurate battery levels -- **Head Gestures**: Answer calls just by nodding your head -- **Conversational Awareness**: Volume automatically lowers when you speak -- **Hearing Aid\*** -- **Customize Transparency Mode\*** -- **Multi-device connectivity\*** (upto 2 devices) -- **Other customizations**: - - Rename your AirPods - - Customize long-press actions - - Few accessibility features - - And more! +## Find My -See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap. +The following features related to Find My are planned, but require further RE and might need root on Android: -## Platform Support +- Add your AirPods to the Find My network +- Play sound through charging case to find it +- Notify when leaving behind +- Toggle case charging sounds -### Linux +## Spatial Audio -The Linux version runs as a system tray app. Connect your AirPods and enjoy: +The app does not currently provide head tracking information to Android for the OS to perform HRTF. This has not been explored completely, and it might need root. -- Battery monitoring -- Automatic Ear detection -- Conversational Awareness -- Switching Noise Control modes -- Device renaming +Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this. -> [!NOTE] -> Work in progress, but core functionality is stable and usable. +## Heart Rate Monitoring (AirPods Pro 3 and later) +This is being worked upon, check the #⁠reverse-engineering channel on the LibrePods Discord server for more information. If it is ever implemented, it will most likely need root on Android. -For installation and detailed info, see the [Linux README](/linux/README.md). +## High quality two-way audio +On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP. -### Android +Since this needs deeper integration with audio on Android, it will most likely need root. -#### Screenshots +# Installation -| | | | -| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- | -| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) | -| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) | -| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) | -| ![Customizations 2](/android/imgs/customizations-2.png) | ![accessibility](/android/imgs/accessibility.png) | ![transparency](/android/imgs/transparency.png) | -| ![hearing-aid](/android/imgs/hearing-aid.png) | ![hearing-test](/android/imgs/hearing-test.png) | ![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) | +- [**Android**](/android/README.md) +- [**Linux**](/linux/README.md) +# VendorID Spoofing -here's a very unprofessional demo video +Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features! -https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533 +You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings (shown only when Xposed is available and LibrePods module is enabled). -#### Root Requirement - -> [!CAUTION] -> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. -> -> There are **no exceptions** to the root requirement until Google merges the fix. - -Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features. - -## Bluetooth DID (Device Identification) Hook - -Turns out, if you change the manufacturerid to that of Apple, you get access to several special features! - -### Multi-device Connectivity +## Multi-device Connectivity Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over. -### Accessibility Settings and Hearing Aid +## Accessibility Settings and Hearing Aid Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured. -All hearing aid customizations can be done from Android, including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. - -To enable these features, enable App Settings -> `act as Apple Device`. - -#### A few notes - -- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced. - -- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear. - -- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android. - -- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module. - -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=kavishdevar/librepods&type=Date)](https://star-history.com/#kavishdevar/librepods&Date) +All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. + +# Protocol and Reverse Engineering + +Please refer to the Wireshark dissector plugin by Nojus ([@pabloaul](https://github.com/pabloaul)) for more information on the protocols used: [pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark) + +The dissector had not been used in LibrePods for most of the implementation; I had reverse engineered the protocol myself before this dissector was made. But many (future) features including two-way high quality audio and spatial audio would not have been possible without their RE efforts! + +# Use of AI + +## Android app + +These parts of the app were completely AI-generated: +- Head Gestures - all of it, including logic and the UI +- The offset setup with r2+the xposed module (both versions) +- Troubleshooter and LogCollector + +Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually. + +Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). + +## Linux (rewrite) + +The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated. + +# Supporters + +A huge thank you to everyone supporting the project! + + + + + + + + + + + + + + + + + + + + + +
+ + davdroman
+ @davdroman +
+
+ + tedsalmon
+ @tedsalmon +
+
+ + wiless
+ @wiless +
+
+ + SmartMsg
+ @SmartMsg +
+
+ + lunaroyster
+ @lunaroyster +
+
+ + ressiwage
+ @ressiwage +
+
+ + kkjdroid
+ @kkjdroid +
+
+ + CitrusJoules
+ @CitrusJoules +
+
+ + DanielReyesDev
+ @DanielReyesDev +
+
+ + sumitduster
+ @sumitduster +
+
+ + GrifTheDev
+ @GrifTheDev +
+
+ +# Special Thanks +- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion)) +- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016)) +- @devnoname120 for helping with the first root patch +- @timgromeyer for making the first version of the linux app +- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)! +- Of course, everyone who has contributed to the project in any way, including by testing, sharing feedback, or just showing interest! + +# Alternates for other platforms: +- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted. +- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/)) +- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/)) + +# Star History + + + + + + Star History Chart + + # License @@ -120,15 +238,17 @@ LibrePods - AirPods liberated from Apple’s ecosystem Copyright (C) 2025 LibrePods contributors This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published -by the Free Software Foundation, either version 3 of the License. +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. +GNU General Public License for more details. -You should have received a copy of the GNU Affero General Public License -along with this program over [here](/LICENSE). If not, see . +You should have received a copy of the GNU General Public License +along with this program. If not, see . All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc. + From be62ce18826d521836dda1dd8a01d5078341895d Mon Sep 17 00:00:00 2001 From: Debarka Kundu Date: Wed, 20 May 2026 04:03:02 +0530 Subject: [PATCH 8/8] fix: resolve listening mode render-loop and off mode visibility in segmented control --- linux-rust/src/ui/airpods.rs | 34 ++++------------------------------ linux-rust/src/ui/window.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 9f106042f..7d151cd81 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -32,8 +32,6 @@ fn listening_mode_button<'a>( icon_bytes: Option<&'static [u8]>, label: &'a str, mac: String, - state: AirPodsState, - aacp_manager: Arc, ) -> iced::Element<'a, Message> { let icon_element: iced::Element<'a, Message> = if let Some(bytes) = icon_bytes { image(image::Handle::from_bytes(bytes)) @@ -74,7 +72,6 @@ fn listening_mode_button<'a>( .align_x(Center) .width(Length::Fill); - let mode_clone = mode.clone(); button(content) .padding(Padding { top: 10.0, @@ -104,23 +101,8 @@ fn listening_mode_button<'a>( style.text_color = theme.palette().text; style }) - .on_press({ - let aacp_manager = aacp_manager.clone(); - let mode_byte = mode_clone.to_byte(); - let selected_mode = mode.clone(); - run_async_in_thread(async move { - aacp_manager - .send_control_command( - ControlCommandIdentifiers::ListeningMode, - &[mode_byte], - ) - .await - .expect("Failed to send Noise Control Mode command"); - }); - let mut new_state = state.clone(); - new_state.noise_control_mode = selected_mode; - Message::StateChanged(mac, DeviceState::AirPods(new_state)) - }) + // Only send a message — side effects (AACP command) are handled in update() + .on_press(Message::SetListeningMode(mac, mode)) .into() } @@ -205,16 +187,14 @@ pub fn airpods_view<'a>( // --- Segmented listening mode control --- let mut mode_buttons: Vec> = Vec::new(); - // Conditionally include "Off" based on allow_off_mode (from device) AND show_off_listening_mode (from settings) - if state.allow_off_mode && show_off_listening_mode { + // Conditionally include "Off" based on the app setting + if show_off_listening_mode { mode_buttons.push(listening_mode_button( AirPodsNoiseControlMode::Off, state.noise_control_mode == AirPodsNoiseControlMode::Off, None, "Off", mac.clone(), - state.clone(), - aacp_manager.clone(), )); } @@ -224,8 +204,6 @@ pub fn airpods_view<'a>( Some(ICON_NOISE_CANCELLATION), "Noise Cancel", mac.clone(), - state.clone(), - aacp_manager.clone(), )); mode_buttons.push(listening_mode_button( @@ -234,8 +212,6 @@ pub fn airpods_view<'a>( Some(ICON_TRANSPARENCY), "Transparency", mac.clone(), - state.clone(), - aacp_manager.clone(), )); mode_buttons.push(listening_mode_button( @@ -244,8 +220,6 @@ pub fn airpods_view<'a>( Some(ICON_ADAPTIVE), "Adaptive", mac.clone(), - state.clone(), - aacp_manager.clone(), )); let listening_mode = container( diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 531605dad..aa1818ab6 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -117,6 +117,7 @@ pub enum Message { ConnectDevice(String), ConnectResult(String, bool), ShowOffListeningModeChanged(bool), + SetListeningMode(String, AirPodsNoiseControlMode), } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -688,6 +689,32 @@ impl App { self.connecting_devices.remove(&mac); Task::none() } + Message::SetListeningMode(mac, mode) => { + // Send the AACP command to change the listening mode + let device_managers = self.device_managers.blocking_read(); + if let Some(managers) = device_managers.get(&mac) { + if let Some(aacp_manager) = managers.get_aacp() { + let mode_byte = mode.to_byte(); + let aacp = aacp_manager.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async move { + if let Err(e) = aacp.send_control_command( + ControlCommandIdentifiers::ListeningMode, + &[mode_byte], + ).await { + log::error!("Failed to send Noise Control Mode command: {}", e); + } + }); + }); + } + } + // Update the local UI state + if let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) { + state.noise_control_mode = mode; + } + Task::none() + } } }