diff --git a/README.md b/README.md
index c533b170e..10eb54471 100644
--- a/README.md
+++ b/README.md
@@ -1,118 +1,236 @@
-
+>[!IMPORTANT]
+Development paused due to lack of time until June 2026 (JEE Advanced). PRs and issues might not be responded to until then.
-[](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/)
-[](https://github.com/kavishdevar/librepods/releases/latest)
-[](https://github.com/kavishdevar/librepods/releases)
-[](https://github.com/kavishdevar/librepods/stargazers)
-[](https://github.com/kavishdevar/librepods/issues)
-[](https://github.com/kavishdevar/librepods/blob/main/LICENSE)
-[](https://github.com/kavishdevar/librepods/graphs/contributors)
+---
+
+
+
+
+
-## 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 Android On 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
-| | | |
-| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
-|  |  |  |
-|  |  |  |
-|  |  |  |
-|  |  |  |
-|  |  |  |
+- [**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
-
-[](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!
+
+
+
+# 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
+
+
+
+
+
+
+
+
# 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.
+
diff --git a/linux-rust/assets/icons/adaptive.png b/linux-rust/assets/icons/adaptive.png
new file mode 100644
index 000000000..b0863357c
Binary files /dev/null and b/linux-rust/assets/icons/adaptive.png differ
diff --git a/linux-rust/assets/icons/noise_cancellation.png b/linux-rust/assets/icons/noise_cancellation.png
new file mode 100644
index 000000000..0b4ec6e88
Binary files /dev/null and b/linux-rust/assets/icons/noise_cancellation.png differ
diff --git a/linux-rust/assets/icons/transparency.png b/linux-rust/assets/icons/transparency.png
new file mode 100644
index 000000000..b03b74cbf
Binary files /dev/null and b/linux-rust/assets/icons/transparency.png differ
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/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 9335b5e05..7d151cd81 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, 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,14 +14,106 @@ 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,
+) -> 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);
+
+ 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
+ })
+ // Only send a message — side effects (AACP command) are handled in update()
+ .on_press(Message::SetListeningMode(mac, mode))
+ .into()
+}
+
pub fn airpods_view<'a>(
mac: &'a str,
devices_list: &HashMap,
state: &'a AirPodsState,
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();
@@ -92,92 +184,84 @@ pub fn airpods_view<'a>(
style
});
+ // --- Segmented listening mode control ---
+ let mut mode_buttons: Vec> = Vec::new();
+
+ // 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(),
+ ));
+ }
+
+ mode_buttons.push(listening_mode_button(
+ AirPodsNoiseControlMode::NoiseCancellation,
+ state.noise_control_mode == AirPodsNoiseControlMode::NoiseCancellation,
+ Some(ICON_NOISE_CANCELLATION),
+ "Noise Cancel",
+ mac.clone(),
+ ));
+
+ mode_buttons.push(listening_mode_button(
+ AirPodsNoiseControlMode::Transparency,
+ state.noise_control_mode == AirPodsNoiseControlMode::Transparency,
+ Some(ICON_TRANSPARENCY),
+ "Transparency",
+ mac.clone(),
+ ));
+
+ mode_buttons.push(listening_mode_button(
+ AirPodsNoiseControlMode::Adaptive,
+ state.noise_control_mode == AirPodsNoiseControlMode::Adaptive,
+ Some(ICON_ADAPTIVE),
+ "Adaptive",
+ mac.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();
@@ -309,196 +393,181 @@ 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 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 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.scale_alpha(0.7));
+ style.color = Some(theme.palette().primary);
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{
+ }),
+ ]
+ .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,
})
- .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
- }
- )
- };
+ .on_press(Message::ToggleDeviceInfo);
- 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![
- row![
- text("Model Number").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.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));
+ 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(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| {
+ 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",
@@ -507,20 +576,20 @@ pub fn airpods_view<'a>(
}
}
- container(column![
+ let content = container(column![
rename_input,
Space::new().height(Length::from(20)),
listening_mode,
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)
- .center_x(Length::Fill)
- .height(Length::Fill)
+ .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 d683f0b16..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, 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 mut style = text::Style::default();
- style.color = Some(theme.palette().text);
- 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 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.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(
@@ -158,7 +196,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 +212,10 @@ pub fn nothing_view<'a>(
.padding(20)
])
.padding(20)
- .center_x(Length::Fill)
- .height(Length::Fill)
+ .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 4574b97ce..aa1818ab6 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;
@@ -76,6 +76,10 @@ pub struct App {
selected_device_type: Option,
tray_text_mode: bool,
stem_control: bool,
+ show_serials: bool,
+ show_device_info: bool,
+ show_off_listening_mode: bool,
+ connecting_devices: HashSet,
}
pub struct BluetoothState {
@@ -108,6 +112,12 @@ 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,
+ ConnectDevice(String),
+ ConnectResult(String, bool),
+ ShowOffListeningModeChanged(bool),
+ SetListeningMode(String, AirPodsNoiseControlMode),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -166,6 +176,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();
@@ -217,6 +232,10 @@ impl App {
device_managers,
tray_text_mode,
stem_control,
+ show_serials: false,
+ show_device_info: false,
+ show_off_listening_mode,
+ connecting_devices: HashSet::new(),
},
Task::batch(vec![open_task, wait_task]),
)
@@ -253,6 +272,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 {}: {}",
@@ -354,22 +374,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])
@@ -492,17 +497,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
- });
}
}
_ => {
@@ -608,19 +602,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()
}
@@ -631,6 +615,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 {}: {}",
@@ -647,6 +632,35 @@ 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 {}: {}",
+ app_settings_path.to_str().unwrap(),
+ settings
+ );
+ 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()
+ }
+ 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 {}: {}",
@@ -656,6 +670,51 @@ impl App {
std::fs::write(app_settings_path, settings.to_string()).ok();
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()
+ }
+ 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()
+ }
}
}
@@ -864,6 +923,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) => {
@@ -875,7 +990,10 @@ impl App {
id,
&devices_list,
state,
- aacp_manager.clone()
+ aacp_manager.clone(),
+ self.show_serials,
+ self.show_device_info,
+ self.show_off_listening_mode,
))
})
}
@@ -883,7 +1001,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)
@@ -893,26 +1011,24 @@ 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(
- 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)
@@ -924,6 +1040,7 @@ impl App {
.center_y(Length::Fill)
}
}
+ }
}
}
Tab::Settings => {
@@ -1112,7 +1229,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);