diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index a2b6d49..11f9e4f 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -200,6 +200,18 @@ pub enum StemPressType { LongPress = 0x08, } +impl StemPressType { + fn from_u8(value: u8) -> Option { + match value { + 0x05 => Some(Self::SinglePress), + 0x06 => Some(Self::DoublePress), + 0x07 => Some(Self::TriplePress), + 0x08 => Some(Self::LongPress), + _ => None, + } + } +} + #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StemPressBudType { @@ -207,6 +219,16 @@ pub enum StemPressBudType { Right = 0x02, } +impl StemPressBudType { + fn from_u8(value: u8) -> Option { + match value { + 0x01 => Some(Self::Left), + 0x02 => Some(Self::Right), + _ => None, + } + } +} + #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AudioSourceType { @@ -283,6 +305,7 @@ pub enum AACPEvent { AudioSource(AudioSource), ConnectedDevices(Vec, Vec), OwnershipToFalseRequest, + StemPress(StemPressType, StemPressBudType), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -795,7 +818,26 @@ impl AACPManager { error!("Failed to save devices: {}", e); } } - opcodes::STEM_PRESS => info!("Received Stem Press packet."), + opcodes::STEM_PRESS => { + if payload.len() < 4 { + error!("Stem Press packet too short: {}", hex::encode(payload)); + return; + } + let press_type = StemPressType::from_u8(payload[2]); + let bud_type = StemPressBudType::from_u8(payload[3]); + if let (Some(press), Some(bud)) = (press_type, bud_type) { + info!("Received Stem Press: {:?} on {:?}", press, bud); + let state = self.state.lock().await; + if let Some(ref tx) = state.event_tx { + let _ = tx.send(AACPEvent::StemPress(press, bud)); + } + } else { + error!( + "Invalid Stem Press packet - type: {:?}, bud: {:?}", + press_type, bud_type + ); + } + } opcodes::AUDIO_SOURCE => { if payload.len() < 9 { error!("Audio Source packet too short: {}", hex::encode(payload)); diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs index e60ea6e..e2fc01e 100644 --- a/linux-rust/src/devices/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -8,6 +8,7 @@ use ksni::Handle; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::Mutex; use tokio::time::{Duration, sleep}; @@ -24,6 +25,7 @@ impl AirPodsDevice { mac_address: Address, tray_handle: Option>, ui_tx: tokio::sync::mpsc::UnboundedSender, + stem_control: Arc, ) -> Self { info!("Creating new AirPodsDevice for {}", mac_address); let mut aacp_manager = AACPManager::new(); @@ -80,6 +82,20 @@ impl AirPodsDevice { error!("Failed to request proximity keys: {}", e); } + if stem_control.load(Ordering::Relaxed) { + // Enable stem press detection (double and triple tap) + // StemConfig bitmask for the control command: single=0x01, double=0x02, triple=0x04, long=0x08 + // We want double and triple: 0x02 | 0x04 = 0x06 + // Note: these bitmask values differ from the StemPressType event enum values (0x05–0x08) + info!("Enabling stem press detection for double and triple tap"); + if let Err(e) = aacp_manager + .send_control_command(ControlCommandIdentifiers::StemConfig, &[0x06]) + .await + { + error!("Failed to enable stem press detection: {}", e); + } + } + let session = bluer::Session::new() .await .expect("Failed to get bluer session"); @@ -206,6 +222,7 @@ impl AirPodsDevice { let local_mac_events = local_mac.clone(); let ui_tx_clone = ui_tx.clone(); let command_tx_clone = command_tx.clone(); + let stem_control_clone = stem_control.clone(); tokio::spawn(async move { while let Some(event) = rx.recv().await { let event_clone = event.clone(); @@ -325,6 +342,31 @@ impl AirPodsDevice { controller.pause_all_media().await; controller.deactivate_a2dp_profile().await; } + AACPEvent::StemPress(press_type, bud_type) => { + use crate::bluetooth::aacp::StemPressType; + info!( + "Received Stem Press: {:?} on {:?}", + press_type, bud_type + ); + if stem_control_clone.load(Ordering::Relaxed) { + let controller = mc_clone.lock().await; + match press_type { + StemPressType::DoublePress => { + info!("Double press detected, skipping to next track"); + controller.next_track().await; + } + StemPressType::TriplePress => { + info!("Triple press detected, going to previous track"); + controller.previous_track().await; + } + _ => { + debug!("Unhandled stem press type: {:?}", press_type); + } + } + } else { + debug!("Stem control disabled, ignoring stem press event"); + } + } _ => { debug!("Received unhandled AACP event: {:?}", event); let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index d81f077..de87df1 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -10,7 +10,7 @@ use crate::bluetooth::managers::DeviceManagers; use crate::devices::enums::DeviceData; use crate::ui::messages::BluetoothUIMessage; use crate::ui::tray::MyTray; -use crate::utils::get_devices_path; +use crate::utils::{get_app_settings_path, get_devices_path}; use bluer::{Address, InternalErrorKind}; use clap::Parser; use dbus::arg::{RefArg, Variant}; @@ -19,9 +19,10 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; use dbus::message::MatchRule; use devices::airpods::AirPodsDevice; use ksni::TrayMethods; -use log::info; +use log::{info, warn}; use std::collections::HashMap; use std::env; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::sync::RwLock; use tokio::sync::mpsc::unbounded_channel; @@ -44,6 +45,11 @@ struct Args { le_debug: bool, #[arg(long, short = 'v', help = "Show application version and exit")] version: bool, + #[arg( + long, + help = "Disable stem press track control (use this if your environment already handles AirPods AVRCP commands natively)" + )] + no_stem_control: bool, } fn main() -> iced::Result { @@ -59,10 +65,10 @@ fn main() -> iced::Result { let log_level = if args.debug { "debug" } else { "info" }; let wayland_display = env::var("WAYLAND_DISPLAY").is_ok(); + if wayland_display && env::var("WGPU_BACKEND").is_err() { + unsafe { env::set_var("WGPU_BACKEND", "gl") }; + } if env::var("RUST_LOG").is_err() { - if wayland_display { - unsafe { env::set_var("WGPU_BACKEND", "gl") }; - } unsafe { env::set_var( "RUST_LOG", @@ -80,19 +86,42 @@ fn main() -> iced::Result { let device_managers: Arc>> = Arc::new(RwLock::new(HashMap::new())); - let device_managers_clone = device_managers.clone(); - std::thread::spawn(|| { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async_main(ui_tx, device_managers_clone)) - .unwrap(); - }); - ui::window::start_ui(ui_rx, args.start_minimized, device_managers) + // Load stem_control initial value from settings JSON, then apply CLI override. + let app_settings_path = get_app_settings_path(); + let saved_stem_control = std::fs::read_to_string(&app_settings_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("stem_control").and_then(|b| b.as_bool())) + .unwrap_or(true); + // CLI --no-stem-control overrides the saved setting. + let stem_control_initial = if args.no_stem_control { false } else { saved_stem_control }; + let stem_control: Arc = Arc::new(AtomicBool::new(stem_control_initial)); + + if args.no_tray { + // Run headless without UI + info!("Running in headless mode (no GUI)"); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async_main(ui_tx, device_managers, stem_control)).unwrap(); + Ok(()) + } else { + // Run with UI + let device_managers_clone = device_managers.clone(); + let stem_control_clone = stem_control.clone(); + std::thread::spawn(|| { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async_main(ui_tx, device_managers_clone, stem_control_clone)) + .unwrap(); + }); + + ui::window::start_ui(ui_rx, args.start_minimized, device_managers, stem_control) + } } async fn async_main( ui_tx: tokio::sync::mpsc::UnboundedSender, device_managers: Arc>>, + stem_control: Arc, ) -> bluer::Result<()> { let args = Args::parse(); @@ -160,7 +189,7 @@ async fn async_main( .unwrap_or_else(|| "Unknown".to_string()); info!("Found connected AirPods: {}, initializing.", name); let airpods_device = - AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone()).await; + AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone(), stem_control.clone()).await; let mut managers = device_managers.write().await; // let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone()); @@ -170,11 +199,11 @@ async fn async_main( .or_insert(dev_managers) .set_aacp(airpods_device.aacp_manager); drop(managers); - ui_tx - .send(BluetoothUIMessage::DeviceConnected( - device.address().to_string(), - )) - .unwrap(); + if let Err(e) = ui_tx.send(BluetoothUIMessage::DeviceConnected( + device.address().to_string(), + )) { + warn!("Failed to send DeviceConnected UI message: {:?}", e); + } } Err(_) => { info!("No connected AirPods found."); @@ -205,9 +234,9 @@ async fn async_main( .entry(addr_str.clone()) .or_insert(dev_managers) .set_att(dev.att_manager); - ui_tx_clone - .send(BluetoothUIMessage::DeviceConnected(addr_str)) - .unwrap(); + if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str)) { + warn!("Failed to send DeviceConnected UI message: {:?}", e); + } } drop(managers) }); @@ -280,9 +309,9 @@ async fn async_main( .or_insert(dev_managers) .set_att(dev.att_manager); drop(managers); - ui_tx_clone - .send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) - .unwrap(); + if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) { + warn!("Failed to send DeviceConnected UI message: {:?}", e); + } }); } return true; @@ -298,8 +327,9 @@ async fn async_main( let handle_clone = tray_handle.clone(); let ui_tx_clone = ui_tx.clone(); let device_managers = device_managers.clone(); + let stem_control_arc = stem_control.clone(); tokio::spawn(async move { - let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone()).await; + let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone(), stem_control_arc.clone()).await; let mut managers = device_managers.write().await; // let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone()); let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone()); @@ -308,9 +338,9 @@ async fn async_main( .or_insert(dev_managers) .set_aacp(airpods_device.aacp_manager); drop(managers); - ui_tx_clone - .send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) - .unwrap(); + if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) { + warn!("Failed to send DeviceConnected UI message: {:?}", e); + } }); true })?; diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 73fbf24..9f97776 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -594,6 +594,140 @@ impl MediaController { } } + pub async fn next_track(&self) { + debug!("Skipping to next track"); + info!("Skipping to next track"); + + tokio::task::spawn_blocking(|| { + let conn = Connection::new_session().unwrap(); + let proxy = conn.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(5), + ); + let (names,): (Vec,) = proxy + .method_call("org.freedesktop.DBus", "ListNames", ()) + .unwrap(); + + // Find playing services + let mut playing_service = None; + let mut fallback_service = None; + + for service in names { + if !service.starts_with("org.mpris.MediaPlayer2.") { + continue; + } + if Self::is_kdeconnect_service(&service) { + debug!("Skipping kdeconnect service: {}", service); + continue; + } + + let proxy = + conn.with_proxy(&service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + + // Check if this player is currently playing + if let Ok(playback_status) = proxy.get::("org.mpris.MediaPlayer2.Player", "PlaybackStatus") { + debug!("Service {} has status: {}", service, playback_status); + if playback_status == "Playing" && playing_service.is_none() { + playing_service = Some(service.clone()); + } + } + + if fallback_service.is_none() { + fallback_service = Some(service); + } + } + + // Prefer playing service, fallback to first available + if let Some(service) = playing_service.or(fallback_service) { + debug!("Sending Next command to service: {}", service); + let proxy = + conn.with_proxy(&service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + if proxy + .method_call::<(), _, &str, &str>( + "org.mpris.MediaPlayer2.Player", + "Next", + (), + ) + .is_ok() + { + info!("Skipped to next track on: {}", service); + } else { + debug!("Failed to skip track on service: {}", service); + } + } + }) + .await + .unwrap(); + } + + pub async fn previous_track(&self) { + debug!("Going to previous track"); + info!("Going to previous track"); + + tokio::task::spawn_blocking(|| { + let conn = Connection::new_session().unwrap(); + let proxy = conn.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(5), + ); + let (names,): (Vec,) = proxy + .method_call("org.freedesktop.DBus", "ListNames", ()) + .unwrap(); + + // Find playing services + let mut playing_service = None; + let mut fallback_service = None; + + for service in names { + if !service.starts_with("org.mpris.MediaPlayer2.") { + continue; + } + if Self::is_kdeconnect_service(&service) { + debug!("Skipping kdeconnect service: {}", service); + continue; + } + + let proxy = + conn.with_proxy(&service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + + // Check if this player is currently playing + if let Ok(playback_status) = proxy.get::("org.mpris.MediaPlayer2.Player", "PlaybackStatus") { + debug!("Service {} has status: {}", service, playback_status); + if playback_status == "Playing" && playing_service.is_none() { + playing_service = Some(service.clone()); + } + } + + if fallback_service.is_none() { + fallback_service = Some(service); + } + } + + // Prefer playing service, fallback to first available + if let Some(service) = playing_service.or(fallback_service) { + debug!("Sending Previous command to service: {}", service); + let proxy = + conn.with_proxy(&service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + if proxy + .method_call::<(), _, &str, &str>( + "org.mpris.MediaPlayer2.Player", + "Previous", + (), + ) + .is_ok() + { + info!("Went to previous track on: {}", service); + } else { + debug!("Failed to go to previous track on service: {}", service); + } + } + }) + .await + .unwrap(); + } + async fn is_a2dp_profile_available(&self) -> bool { debug!("Entering is_a2dp_profile_available"); let state = self.state.lock().await; diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4f89faa..3c5c12e 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -25,6 +25,7 @@ use iced::{ }; use log::{debug, error}; use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::{Mutex, RwLock}; @@ -33,13 +34,14 @@ pub fn start_ui( ui_rx: UnboundedReceiver, start_minimized: bool, device_managers: Arc>>, + stem_control: Arc, ) -> iced::Result { daemon(App::title, App::update, App::view) .subscription(App::subscription) .theme(App::theme) .font(include_bytes!("../../assets/font/sf_pro.otf").as_slice()) .default_font(Font::with_name("SF Pro Text")) - .run_with(move || App::new(ui_rx, start_minimized, device_managers)) + .run_with(move || App::new(ui_rx, start_minimized, device_managers, stem_control)) } pub struct App { @@ -57,6 +59,7 @@ pub struct App { device_type_state: combo_box::State, selected_device_type: Option, tray_text_mode: bool, + stem_control: Arc, } pub struct BluetoothState { @@ -88,6 +91,7 @@ pub enum Message { CancelAddDevice, StateChanged(String, DeviceState), TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy + StemControlChanged(bool), } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -108,6 +112,7 @@ impl App { ui_rx: UnboundedReceiver, start_minimized: bool, device_managers: Arc>>, + stem_control: Arc, ) -> (Self, Task) { let (mut panes, first_pane) = pane_grid::State::new(Pane::Sidebar); let split = panes.split(pane_grid::Axis::Vertical, first_pane, Pane::Content); @@ -191,6 +196,7 @@ impl App { selected_device_type: None, device_managers, tray_text_mode, + stem_control, }, Task::batch(vec![open_task, wait_task]), ) @@ -223,7 +229,11 @@ impl App { Message::ThemeSelected(theme) => { self.selected_theme = theme; let app_settings_path = get_app_settings_path(); - let settings = serde_json::json!({"theme": self.selected_theme, "tray_text_mode": self.tray_text_mode}); + let settings = serde_json::json!({ + "theme": self.selected_theme, + "tray_text_mode": self.tray_text_mode, + "stem_control": self.stem_control.load(Ordering::Relaxed), + }); debug!( "Writing settings to {}: {}", app_settings_path.to_str().unwrap(), @@ -588,7 +598,27 @@ impl App { Message::TrayTextModeChanged(is_enabled) => { self.tray_text_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}); + let settings = serde_json::json!({ + "theme": self.selected_theme, + "tray_text_mode": self.tray_text_mode, + "stem_control": self.stem_control.load(Ordering::Relaxed), + }); + debug!( + "Writing settings to {}: {}", + app_settings_path.to_str().unwrap(), + settings + ); + std::fs::write(app_settings_path, settings.to_string()).ok(); + Task::none() + } + Message::StemControlChanged(is_enabled) => { + self.stem_control.store(is_enabled, Ordering::Relaxed); + 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.load(Ordering::Relaxed), + }); debug!( "Writing settings to {}: {}", app_settings_path.to_str().unwrap(), @@ -994,11 +1024,74 @@ impl App { ] .spacing(12); + let stem_control_value = self.stem_control.load(Ordering::Relaxed); + let stem_control_toggle = container( + row![ + column![ + text("Stem press track control").size(16), + text("Double press = next track, triple press = previous track. Disable if your environment handles AirPods AVRCP commands natively.").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(stem_control_value) + .on_toggle(move |is_enabled| { + Message::StemControlChanged(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); + + let controls_settings_col = column![ + container( + text("Controls").size(20).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + } + ) + ) + .padding(Padding{ + top: 0.0, + bottom: 0.0, + left: 18.0, + right: 18.0, + }), + stem_control_toggle + ] + .spacing(12); + container( column![ appearance_settings_col, Space::with_height(Length::from(20)), - tray_text_mode_toggle + tray_text_mode_toggle, + Space::with_height(Length::from(20)), + controls_settings_col, ] ) .padding(20)