diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index bed6ca853..c6ffe2b13 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -3,7 +3,7 @@ use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; use crate::utils::get_devices_path; use bluer::{ Address, AddressType, Error, Result, - l2cap::{SeqPacket, Socket, SocketAddr}, + l2cap::{SeqPacket, Security, SecurityLevel, Socket, SocketAddr}, }; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; @@ -295,6 +295,19 @@ pub struct ConnectedDevice { pub r#type: Option, } +/// Parsed head-tracking sensor sample from a 0x17 stream packet. +/// +/// `orientation*` are the three raw 16-bit orientation values (offsets 43/45/47); +/// `*_accel` are the raw acceleration values (offsets 51/53). All little-endian signed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HeadTrackingData { + pub orientation1: i16, + pub orientation2: i16, + pub orientation3: i16, + pub horizontal_accel: i16, + pub vertical_accel: i16, +} + #[derive(Debug, Clone)] pub enum AACPEvent { BatteryInfo(Vec), @@ -306,6 +319,7 @@ pub enum AACPEvent { ConnectedDevices(Vec, Vec), OwnershipToFalseRequest, StemPress(StemPressType, StemPressBudType), + HeadTracking(HeadTrackingData), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -361,6 +375,9 @@ impl AACPManagerState { pub struct AACPManager { pub state: Arc>, tasks: Arc>>, + /// Whether head-gesture detection should act on the head-tracking stream. + /// Shared so the UI (which holds an AACPManager clone) can toggle it live. + head_gestures: Arc, } impl AACPManager { @@ -368,9 +385,19 @@ impl AACPManager { AACPManager { state: Arc::new(Mutex::new(AACPManagerState::new())), tasks: Arc::new(Mutex::new(JoinSet::new())), + head_gestures: Arc::new(std::sync::atomic::AtomicBool::new(false)), } } + pub fn set_head_gestures_enabled(&self, enabled: bool) { + self.head_gestures + .store(enabled, std::sync::atomic::Ordering::Relaxed); + } + + pub fn head_gestures_enabled(&self) -> bool { + self.head_gestures.load(std::sync::atomic::Ordering::Relaxed) + } + pub async fn connect(&mut self, addr: Address) { info!("AACPManager connecting to {} on PSM {:#06X}...", addr, PSM); let target_sa = SocketAddr::new(addr, AddressType::BrEdr, PSM); @@ -388,6 +415,29 @@ impl AACPManager { } }; + // Diagnostics: log the channel's default security and flow-control mode. + match socket.security() { + Ok(sec) => info!("L2CAP default security before connect: {:?}", sec), + Err(e) => info!("Could not read L2CAP security: {}", e), + } + match socket.flow_control() { + Ok(fc) => info!("L2CAP default flow control before connect: {:?}", fc), + Err(e) => info!("Could not read L2CAP flow control: {}", e), + } + + // AirPods refuse to emit the AAP notification stream over an unencrypted + // channel. bumble (proximity_keys.py) authenticates + encrypts before + // opening the channel; the equivalent on a raw BlueZ L2CAP socket is to + // request an authenticated, encrypted link via BT_SECURITY before connect. + if let Err(e) = socket.set_security(Security { + level: SecurityLevel::Medium, + key_size: 0, + }) { + error!("Failed to set L2CAP security to Medium: {}", e); + } else { + info!("Requested L2CAP security level Medium (encrypted) before connect"); + } + let seq_packet = match tokio::time::timeout(CONNECT_TIMEOUT, socket.connect(target_sa)).await { Ok(Ok(s)) => Arc::new(s), @@ -424,6 +474,13 @@ impl AACPManager { } info!("L2CAP connection established with {}", addr); + { + let sock_ref: &Socket = seq_packet.as_ref().as_ref(); + match sock_ref.security() { + Ok(sec) => info!("L2CAP security after connect: {:?}", sec), + Err(e) => info!("Could not read L2CAP security after connect: {}", e), + } + } let (tx, rx) = mpsc::channel(128); @@ -917,6 +974,40 @@ impl AACPManager { opcodes::EQ_DATA => { debug!("Received EQ Data"); } + opcodes::HEADTRACKING => { + // Two kinds of 0x17 packets arrive: + // - HID/service descriptor plists (variable layout, not sensor data) + // - orientation sensor stream: prefix 04 00 04 00 17 00 00 00 10 00, + // then packet[10] in {0x44, 0x45} and packet[11] == 0x00. + let is_sensor = packet.len() >= 55 + && packet[5] == 0x00 + && packet[6] == 0x00 + && packet[7] == 0x00 + && packet[8] == 0x10 + && packet[9] == 0x00 + && (packet[10] == 0x44 || packet[10] == 0x45) + && packet[11] == 0x00; + if is_sensor { + let le_i16 = |i: usize| i16::from_le_bytes([packet[i], packet[i + 1]]); + let data = HeadTrackingData { + orientation1: le_i16(43), + orientation2: le_i16(45), + orientation3: le_i16(47), + horizontal_accel: le_i16(51), + vertical_accel: le_i16(53), + }; + debug!("Received Head Tracking sample: {:?}", data); + let state = self.state.lock().await; + if let Some(ref tx) = state.event_tx { + let _ = tx.send(AACPEvent::HeadTracking(data)); + } + } else { + debug!( + "Received Head Tracking descriptor/service packet ({} bytes)", + packet.len() + ); + } + } _ => debug!("Received unknown packet with opcode {:#04x}", opcode), } } @@ -1200,6 +1291,25 @@ impl AACPManager { self.send_data_packet(&[0x29, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) .await } + + /// Start the head-tracking / spatial sensor stream (opcode 0x17). + /// The accessory must own the connection for the stream to flow. + pub async fn send_start_head_tracking(&self) -> Result<()> { + self.send_data_packet(&[ + opcodes::HEADTRACKING, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1, 0x02, + 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C, 0x00, 0x00, + ]) + .await + } + + /// Stop the head-tracking / spatial sensor stream (opcode 0x17). + pub async fn send_stop_head_tracking(&self) -> Result<()> { + self.send_data_packet(&[ + opcodes::HEADTRACKING, 0x00, 0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, + 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, + ]) + .await + } } async fn recv_thread(manager: AACPManager, sp: Arc) { diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs index f0e876cf0..51428a192 100644 --- a/linux-rust/src/devices/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -105,6 +105,27 @@ impl AirPodsDevice { } } + // Opt-in head-tracking stream for testing (LIBREPODS_HEADTRACKING=1). + // Streams continuous orientation/acceleration samples once we own the connection. + if std::env::var("LIBREPODS_HEADTRACKING").as_deref() == Ok("1") { + let ht_manager = aacp_manager.clone(); + tokio::spawn(async move { + sleep(Duration::from_secs(3)).await; + info!("Taking ownership for head-tracking stream"); + if let Err(e) = ht_manager + .send_control_command(ControlCommandIdentifiers::OwnsConnection, &[0x01]) + .await + { + error!("Failed to take ownership: {}", e); + } + sleep(Duration::from_millis(500)).await; + info!("Starting head-tracking stream (LIBREPODS_HEADTRACKING=1)"); + if let Err(e) = ht_manager.send_start_head_tracking().await { + error!("Failed to start head tracking: {}", e); + } + }); + } + let session = bluer::Session::new() .await .expect("Failed to get bluer session"); @@ -118,6 +139,23 @@ impl AirPodsDevice { .expect("Failed to get adapter address") .to_string(); + // Notify that the AirPods connected, using their Bluetooth name. + let device_name = match adapter.device(mac_address) { + Ok(dev) => dev + .alias() + .await + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "AirPods".to_string()), + Err(_) => "AirPods".to_string(), + }; + crate::utils::notify( + &device_name, + "Connected", + "audio-headphones-symbolic", + "librepods-connection", + ); + let media_controller = Arc::new(Mutex::new(MediaController::new( mac_address.to_string(), local_mac.clone(), @@ -232,6 +270,8 @@ impl AirPodsDevice { let ui_tx_clone = ui_tx.clone(); let command_tx_clone = command_tx.clone(); tokio::spawn(async move { + let mut gesture_detector = crate::devices::gestures::GestureDetector::new(); + let mut last_ht_ui: Option = None; while let Some(event) = rx.recv().await { let event_clone = event.clone(); match event { @@ -375,6 +415,37 @@ impl AirPodsDevice { debug!("Stem control disabled, ignoring stem press event"); } } + AACPEvent::HeadTracking(data) => { + // Forward to the UI at ~10 Hz (the raw stream is much faster). + let now = std::time::Instant::now(); + let should_send = last_ht_ui + .map_or(true, |t| now.duration_since(t) >= Duration::from_millis(100)); + if should_send { + last_ht_ui = Some(now); + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( + mac_address.to_string(), + event_clone, + )); + } + // Run head-gesture detection when enabled (toggled from the UI). + if aacp_manager_clone_events.head_gestures_enabled() + && let Some(gesture) = + gesture_detector.push(data.horizontal_accel, data.vertical_accel) + { + use crate::devices::gestures::Gesture; + let controller = mc_clone.lock().await; + match gesture { + Gesture::Nod => { + info!("Head gesture: Nod -> play/pause"); + controller.play_pause().await; + } + Gesture::Shake => { + info!("Head gesture: Shake -> next track"); + controller.next_track().await; + } + } + } + } _ => { debug!("Received unhandled AACP event: {:?}", event); let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( diff --git a/linux-rust/src/devices/enums.rs b/linux-rust/src/devices/enums.rs index 5768d1802..1d0e13a7d 100644 --- a/linux-rust/src/devices/enums.rs +++ b/linux-rust/src/devices/enums.rs @@ -58,6 +58,32 @@ pub struct AirPodsState { pub personalized_volume_enabled: bool, pub allow_off_mode: bool, pub battery: Vec, + // Spatial audio / head tracking + pub head_tracking_enabled: bool, + pub head_gestures_enabled: bool, + /// Latest raw head-tracking sample: (orientation1, orientation2, orientation3, + /// horizontal_accel, vertical_accel). + pub head_tracking_sample: Option<(i16, i16, i16, i16, i16)>, + /// Calibration baseline (orientation1, orientation2, orientation3) for re-centering. + pub head_tracking_neutral: Option<(i16, i16, i16)>, +} + +impl AirPodsState { + /// Calibrated (pitch, yaw, roll) in degrees from the latest sample, relative + /// to the neutral baseline. Returns None if no sample yet. + pub fn head_orientation_degrees(&self) -> Option<(f32, f32, f32)> { + let (o1, o2, o3, _, _) = self.head_tracking_sample?; + let (n1, n2, n3) = self.head_tracking_neutral.unwrap_or((0, 0, 0)); + let o1n = (o1 - n1) as f32; + let o2n = (o2 - n2) as f32; + let o3n = (o3 - n3) as f32; + // Matches the head-tracking reference: orientation pair maps to pitch/yaw + // over a ~+-32000 range scaled to +-180 degrees; orientation1 ~ roll/twist. + let pitch = (o2n + o3n) / 2.0 / 32000.0 * 180.0; + let yaw = (o2n - o3n) / 2.0 / 32000.0 * 180.0; + let roll = o1n / 32000.0 * 180.0; + Some((pitch, yaw, roll)) + } } #[derive(Clone, Debug)] diff --git a/linux-rust/src/devices/gestures.rs b/linux-rust/src/devices/gestures.rs new file mode 100644 index 000000000..e9257cf0e --- /dev/null +++ b/linux-rust/src/devices/gestures.rs @@ -0,0 +1,110 @@ +//! Head-gesture detection from the AirPods head-tracking acceleration stream. +//! +//! Operates on the horizontal and vertical acceleration values (offsets 51/53 +//! of the 0x17 sensor packet). A nod ("yes") is a vertical oscillation; a shake +//! ("no") is a horizontal oscillation. The detector looks for several large +//! threshold crossings in one axis within a short window, with that axis clearly +//! dominating the other, then enforces a cooldown to avoid repeat triggers. + +use std::collections::VecDeque; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Gesture { + /// Nodding up/down — typically mapped to play/pause ("yes"). + Nod, + /// Shaking left/right — typically mapped to next track ("no"). + Shake, +} + +const WINDOW: usize = 25; // ~0.8s at the ~30 Hz sample rate +const CROSS_THRESHOLD: f32 = 300.0; // magnitude a swing must exceed to count +const MIN_CROSSINGS: u32 = 3; // distinct large swings needed for an oscillation +const DOMINANCE: f32 = 1.8; // active axis peak-to-peak must beat the other by this +const COOLDOWN_SAMPLES: u32 = 30; // ignore input briefly after a detection + +pub struct GestureDetector { + vertical: VecDeque, + horizontal: VecDeque, + cooldown: u32, +} + +impl GestureDetector { + pub fn new() -> Self { + Self { + vertical: VecDeque::with_capacity(WINDOW), + horizontal: VecDeque::with_capacity(WINDOW), + cooldown: 0, + } + } + + pub fn push(&mut self, horizontal: i16, vertical: i16) -> Option { + push_capped(&mut self.horizontal, horizontal as f32); + push_capped(&mut self.vertical, vertical as f32); + + if self.cooldown > 0 { + self.cooldown -= 1; + return None; + } + if self.vertical.len() < WINDOW { + return None; + } + + let (v_cross, v_pp) = analyze(&self.vertical); + let (h_cross, h_pp) = analyze(&self.horizontal); + + // Nod: vertical oscillation dominating the horizontal axis. + if v_cross >= MIN_CROSSINGS && v_pp > h_pp * DOMINANCE { + self.reset(); + return Some(Gesture::Nod); + } + // Shake: horizontal oscillation dominating the vertical axis. + if h_cross >= MIN_CROSSINGS && h_pp > v_pp * DOMINANCE { + self.reset(); + return Some(Gesture::Shake); + } + None + } + + fn reset(&mut self) { + self.vertical.clear(); + self.horizontal.clear(); + self.cooldown = COOLDOWN_SAMPLES; + } +} + +fn push_capped(buf: &mut VecDeque, v: f32) { + if buf.len() == WINDOW { + buf.pop_front(); + } + buf.push_back(v); +} + +/// Returns (number of large threshold crossings, peak-to-peak amplitude). +fn analyze(buf: &VecDeque) -> (u32, f32) { + let mut min = f32::MAX; + let mut max = f32::MIN; + // Count sign changes of the signal where the swing exceeds the threshold: + // an oscillation alternates above +T and below -T. + let mut crossings = 0u32; + let mut last_sign = 0i8; + for &v in buf { + if v < min { + min = v; + } + if v > max { + max = v; + } + let sign = if v > CROSS_THRESHOLD { + 1 + } else if v < -CROSS_THRESHOLD { + -1 + } else { + 0 + }; + if sign != 0 && sign != last_sign { + crossings += 1; + last_sign = sign; + } + } + (crossings, max - min) +} diff --git a/linux-rust/src/devices/mod.rs b/linux-rust/src/devices/mod.rs index c5d459f7c..f3e4762bb 100644 --- a/linux-rust/src/devices/mod.rs +++ b/linux-rust/src/devices/mod.rs @@ -1,3 +1,4 @@ pub mod airpods; pub mod enums; +pub mod gestures; pub(crate) mod nothing; diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index f43f575b2..31fa42e91 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -274,6 +274,18 @@ async fn async_main( return true; }; if is_connected==0 { + // Notify on AirPods disconnect (only for our AirPods, identified by UUID). + if uuids.iter().any(|u| u.to_lowercase() == target_uuid) { + let name = proxy + .get::("org.bluez.Device1", "Name") + .unwrap_or_else(|_| "AirPods".to_string()); + crate::utils::notify( + &name, + "Disconnected", + "audio-headphones-symbolic", + "librepods-connection", + ); + } if let Err(e) = ui_tx.send(BluetoothUIMessage::DeviceDisconnected(addr_str.clone())) { warn!("Failed to send DeviceConnected UI message: {:?}", e); } diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 5bd68b1a2..28ce7f320 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -522,6 +522,11 @@ impl MediaController { self.mpris_command("Previous").await; } + pub async fn play_pause(&self) { + info!("Toggling play/pause"); + self.mpris_command("PlayPause").await; + } + async fn is_a2dp_profile_available(&self) -> bool { let index = match self.state.lock().await.device_index { Some(i) => i, diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 9335b5e05..477fcf315 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -5,7 +5,8 @@ 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, progress_bar, row, rule, scrollable, text, + text_input, toggler, }; use iced::{Background, Border, Center, Color, Length, Padding, Theme}; use log::error; @@ -362,6 +363,69 @@ pub fn airpods_view<'a>( ) }; + // ----- Spatial Audio / Head Tracking ----- + // AirPods Max gate motion data behind a ProtectedAccess HID relay that no + // host can open without Apple's authentication, so head tracking only works + // on models that use the Pro-style 0x17 stream. + let is_airpods_max = devices_list + .get(mac_information.as_str()) + .and_then(|d| d.information.as_ref()) + .and_then(|info| match info { + DeviceInformation::AirPods(a) => Some(a.model_number.clone()), + _ => None, + }) + .map(|m| matches!(m.as_str(), "A2096" | "A3184")) + .unwrap_or(false); + + let spatial_header = container(text("Spatial Audio").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, + }); + + let spatial_audio_col = if is_airpods_max { + column![ + spatial_header, + container( + column![ + text("Head tracking is not available on AirPods Max.").size(15), + text("Apple gates the Max's motion sensor behind an authenticated (ProtectedAccess) HID service, so head orientation can't be read on Linux. Head tracking works on AirPods Pro.") + .size(12) + .style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }), + ] + .spacing(6) + .padding(8) + ) + .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 + }) + ] + .spacing(12) + } else { + spatial_audio_controls(&mac, state, aacp_manager.clone(), spatial_header) + }; + 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 { @@ -507,22 +571,200 @@ pub fn airpods_view<'a>( } } - container(column![ + container(scrollable(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)), + spatial_audio_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) } +/// Builds the interactive Spatial Audio / head-tracking controls (for models +/// that support the Pro-style head-tracking stream). +fn spatial_audio_controls( + mac: &str, + state: &AirPodsState, + aacp_manager: Arc, + header: iced::widget::Container<'static, Message>, +) -> iced::widget::Column<'static, Message> { + let (ht_pitch, ht_yaw, ht_roll) = + state.head_orientation_degrees().unwrap_or((0.0, 0.0, 0.0)); + + let axis = |label: &'static str, value: f32| -> iced::Element<'static, Message> { + let v = value.clamp(-90.0, 90.0); + row![ + text(label).size(14).width(Length::from(55)), + progress_bar(-90.0..=90.0, v) + .length(Length::Fill) + .girth(Length::from(10)), + text(format!("{:+.0}\u{00B0}", value)) + .size(14) + .width(Length::from(50)), + ] + .align_y(Center) + .spacing(10) + .into() + }; + + let recenter_msg = { + let mut s = state.clone(); + s.head_tracking_neutral = s.head_tracking_sample.map(|(o1, o2, o3, _, _)| (o1, o2, o3)); + Message::StateChanged(mac.to_string(), DeviceState::AirPods(s)) + }; + + let aacp_manager_ht = aacp_manager.clone(); + let head_tracking_toggle = { + let mac = mac.to_string(); + let state = state.clone(); + row![ + column![ + text("Head Tracking").size(16), + text("Streams live head orientation from the AirPods motion sensors. Required for gestures and spatial audio.") + .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.head_tracking_enabled) + .on_toggle(move |is_enabled| { + let aacp_manager = aacp_manager_ht.clone(); + run_async_in_thread(async move { + if is_enabled { + let _ = aacp_manager + .send_control_command(ControlCommandIdentifiers::OwnsConnection, &[0x01]) + .await; + let _ = aacp_manager.send_start_head_tracking().await; + } else { + let _ = aacp_manager.send_stop_head_tracking().await; + } + }); + let mut state = state.clone(); + state.head_tracking_enabled = is_enabled; + Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) + }) + .spacing(0) + .size(20), + ] + .align_y(Center) + .spacing(8) + }; + + let aacp_manager_hg = aacp_manager.clone(); + let head_gestures_toggle = { + let mac = mac.to_string(); + let state = state.clone(); + row![ + column![ + text("Head Gestures").size(16), + text("Nod to play/pause, shake to skip to the next track. (Experimental.)") + .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.head_gestures_enabled) + .on_toggle(move |is_enabled| { + aacp_manager_hg.set_head_gestures_enabled(is_enabled); + let mut state = state.clone(); + state.head_gestures_enabled = is_enabled; + Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) + }) + .spacing(0) + .size(20), + ] + .align_y(Center) + .spacing(8) + }; + + let live_label = if state.head_tracking_sample.is_some() { + "Live orientation" + } else { + "Live orientation (enable Head Tracking and move your head)" + }; + + column![ + header, + container( + column![ + head_tracking_toggle, + rule::horizontal(1).style(|theme: &Theme| rule::Style { + color: theme.palette().text.scale_alpha(0.2), + radius: Radius::from(12), + fill_mode: FillMode::Full, + snap: false + }), + column![ + text(live_label).size(13).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }), + axis("Pitch", ht_pitch), + axis("Yaw", ht_yaw), + axis("Roll", ht_roll), + container( + button(text("Re-center").size(14)) + .on_press(recenter_msg) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = + Some(Background::Color(theme.palette().primary.scale_alpha(0.3))); + style.border = Border::default().rounded(8.0); + style + }) + .padding(8) + ) + .align_x(End), + ] + .spacing(8), + rule::horizontal(1).style(|theme: &Theme| rule::Style { + color: theme.palette().text.scale_alpha(0.2), + radius: Radius::from(12), + fill_mode: FillMode::Full, + snap: false + }), + head_gestures_toggle, + ] + .spacing(8) + .padding(8) + ) + .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 + }) + ] + .spacing(12) +} + fn run_async_in_thread(fut: F) where F: Future + Send + 'static, diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs index b3adbc53a..777ed6948 100644 --- a/linux-rust/src/ui/tray.rs +++ b/linux-rust/src/ui/tray.rs @@ -65,7 +65,8 @@ impl ksni::Tray for MyTray { } }; let any_bud_charging = matches!(self.battery_l_status, Some(BatteryStatus::Charging)) - || matches!(self.battery_r_status, Some(BatteryStatus::Charging)); + || matches!(self.battery_r_status, Some(BatteryStatus::Charging)) + || matches!(self.battery_headphone_status, Some(BatteryStatus::Charging)); let app_settings_path = get_app_settings_path(); let settings = std::fs::read_to_string(&app_settings_path) .ok() diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..6b9d238f0 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -382,6 +382,10 @@ impl App { status.identifier == ControlCommandIdentifiers::AllowOffOption && matches!(status.value.as_slice(), [0x01]) }), + head_tracking_enabled: false, + head_gestures_enabled: false, + head_tracking_sample: None, + head_tracking_neutral: None, })); } Some(DeviceType::Nothing) => { @@ -517,6 +521,19 @@ impl App { debug!("Updated battery info for {}: {:?}", mac, state.battery); } } + AACPEvent::HeadTracking(data) => { + if let Some(DeviceState::AirPods(state)) = + self.device_states.get_mut(&mac) + { + state.head_tracking_sample = Some(( + data.orientation1, + data.orientation2, + data.orientation3, + data.horizontal_accel, + data.vertical_accel, + )); + } + } _ => {} } Task::batch(vec![wait_task]) diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 88ee466a8..c6c1b7ed1 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -51,6 +51,34 @@ pub fn get_app_settings_path() -> PathBuf { new_path } +/// Show a transient desktop notification via `notify-send` (best-effort). +/// +/// `tag` is used as a synchronous hint so repeated notifications of the same +/// kind (e.g. noise-control changes) replace each other in place instead of +/// stacking. Spawns and does not wait; failures are logged, never fatal. +pub fn notify(summary: &str, body: &str, icon: &str, tag: &str) { + let mut cmd = std::process::Command::new("notify-send"); + cmd.arg("--app-name=LibrePods") + .arg("--expire-time=1500") + // transient: GNOME shows the banner briefly and does not keep it in the + // notification list (GNOME ignores expire-time for the banner itself). + .arg("--hint=int:transient:1") + // synchronous: repeated notifications of the same kind replace in place. + .arg(format!("--hint=string:x-canonical-private-synchronous:{tag}")) + .arg("--hint=string:synchronous:".to_string() + tag) + .arg("--icon") + .arg(icon) + .arg(summary); + if !body.is_empty() { + cmd.arg(body); + } + cmd.stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if let Err(e) = cmd.spawn() { + log::warn!("Failed to send notification via notify-send: {}", e); + } +} + fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let mut swapped_key = *key; swapped_key.reverse();