Skip to content

Commit a0cdbb2

Browse files
linux-rust(feat): add stem press track control and headless mode support (#469)
* feat: add stem press track control and headless mode support - Parse STEM_PRESS packets and emit AACPEvent::StemPress with press type and bud side - Enable double/triple tap detection on init via StemConfig control command (0x06) - Double press → next track, triple press → previous track via MPRIS D-Bus - Add next_track() and previous_track() to MediaController - Add --no-tray flag for headless operation without a GUI - Replace unwrap() on ui_tx.send() calls with graceful warn! logging (vibecoded) * Update main.rs * feat: make stem press track control optional with GUI toggle Add a --no-stem-control CLI flag and a toggle in the Settings tab for environments that handle AirPods AVRCP commands natively (e.g. via BlueZ/PipeWire). The feature remains enabled by default. - Load stem_control from app settings JSON on startup; --no-stem-control overrides it to false regardless of the saved value - Share an Arc<AtomicBool> between the async backend and the GUI thread; AirPodsDevice holds the Arc directly so the event loop reads the live value on every stem press — toggle takes effect immediately without reconnecting - Persist stem_control to settings JSON alongside theme and tray_text_mode - Add a "Controls" section to the Settings tab with a toggler labelled "Stem press track control", with a subtitle explaining the AVRCP conflict scenario - Fix StemConfig bitmask comment to clarify it uses a separate numbering scheme from the StemPressType event enum values (0x05–0x08)
1 parent decf070 commit a0cdbb2

5 files changed

Lines changed: 373 additions & 32 deletions

File tree

linux-rust/src/bluetooth/aacp.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,35 @@ pub enum StemPressType {
200200
LongPress = 0x08,
201201
}
202202

203+
impl StemPressType {
204+
fn from_u8(value: u8) -> Option<Self> {
205+
match value {
206+
0x05 => Some(Self::SinglePress),
207+
0x06 => Some(Self::DoublePress),
208+
0x07 => Some(Self::TriplePress),
209+
0x08 => Some(Self::LongPress),
210+
_ => None,
211+
}
212+
}
213+
}
214+
203215
#[repr(u8)]
204216
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205217
pub enum StemPressBudType {
206218
Left = 0x01,
207219
Right = 0x02,
208220
}
209221

222+
impl StemPressBudType {
223+
fn from_u8(value: u8) -> Option<Self> {
224+
match value {
225+
0x01 => Some(Self::Left),
226+
0x02 => Some(Self::Right),
227+
_ => None,
228+
}
229+
}
230+
}
231+
210232
#[repr(u8)]
211233
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212234
pub enum AudioSourceType {
@@ -283,6 +305,7 @@ pub enum AACPEvent {
283305
AudioSource(AudioSource),
284306
ConnectedDevices(Vec<ConnectedDevice>, Vec<ConnectedDevice>),
285307
OwnershipToFalseRequest,
308+
StemPress(StemPressType, StemPressBudType),
286309
}
287310

288311
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -795,7 +818,26 @@ impl AACPManager {
795818
error!("Failed to save devices: {}", e);
796819
}
797820
}
798-
opcodes::STEM_PRESS => info!("Received Stem Press packet."),
821+
opcodes::STEM_PRESS => {
822+
if payload.len() < 4 {
823+
error!("Stem Press packet too short: {}", hex::encode(payload));
824+
return;
825+
}
826+
let press_type = StemPressType::from_u8(payload[2]);
827+
let bud_type = StemPressBudType::from_u8(payload[3]);
828+
if let (Some(press), Some(bud)) = (press_type, bud_type) {
829+
info!("Received Stem Press: {:?} on {:?}", press, bud);
830+
let state = self.state.lock().await;
831+
if let Some(ref tx) = state.event_tx {
832+
let _ = tx.send(AACPEvent::StemPress(press, bud));
833+
}
834+
} else {
835+
error!(
836+
"Invalid Stem Press packet - type: {:?}, bud: {:?}",
837+
press_type, bud_type
838+
);
839+
}
840+
}
799841
opcodes::AUDIO_SOURCE => {
800842
if payload.len() < 9 {
801843
error!("Audio Source packet too short: {}", hex::encode(payload));

linux-rust/src/devices/airpods.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use ksni::Handle;
88
use log::{debug, error, info};
99
use serde::{Deserialize, Serialize};
1010
use std::sync::Arc;
11+
use std::sync::atomic::{AtomicBool, Ordering};
1112
use tokio::sync::Mutex;
1213
use tokio::time::{Duration, sleep};
1314

@@ -24,6 +25,7 @@ impl AirPodsDevice {
2425
mac_address: Address,
2526
tray_handle: Option<Handle<MyTray>>,
2627
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
28+
stem_control: Arc<AtomicBool>,
2729
) -> Self {
2830
info!("Creating new AirPodsDevice for {}", mac_address);
2931
let mut aacp_manager = AACPManager::new();
@@ -80,6 +82,20 @@ impl AirPodsDevice {
8082
error!("Failed to request proximity keys: {}", e);
8183
}
8284

85+
if stem_control.load(Ordering::Relaxed) {
86+
// Enable stem press detection (double and triple tap)
87+
// StemConfig bitmask for the control command: single=0x01, double=0x02, triple=0x04, long=0x08
88+
// We want double and triple: 0x02 | 0x04 = 0x06
89+
// Note: these bitmask values differ from the StemPressType event enum values (0x05–0x08)
90+
info!("Enabling stem press detection for double and triple tap");
91+
if let Err(e) = aacp_manager
92+
.send_control_command(ControlCommandIdentifiers::StemConfig, &[0x06])
93+
.await
94+
{
95+
error!("Failed to enable stem press detection: {}", e);
96+
}
97+
}
98+
8399
let session = bluer::Session::new()
84100
.await
85101
.expect("Failed to get bluer session");
@@ -206,6 +222,7 @@ impl AirPodsDevice {
206222
let local_mac_events = local_mac.clone();
207223
let ui_tx_clone = ui_tx.clone();
208224
let command_tx_clone = command_tx.clone();
225+
let stem_control_clone = stem_control.clone();
209226
tokio::spawn(async move {
210227
while let Some(event) = rx.recv().await {
211228
let event_clone = event.clone();
@@ -325,6 +342,31 @@ impl AirPodsDevice {
325342
controller.pause_all_media().await;
326343
controller.deactivate_a2dp_profile().await;
327344
}
345+
AACPEvent::StemPress(press_type, bud_type) => {
346+
use crate::bluetooth::aacp::StemPressType;
347+
info!(
348+
"Received Stem Press: {:?} on {:?}",
349+
press_type, bud_type
350+
);
351+
if stem_control_clone.load(Ordering::Relaxed) {
352+
let controller = mc_clone.lock().await;
353+
match press_type {
354+
StemPressType::DoublePress => {
355+
info!("Double press detected, skipping to next track");
356+
controller.next_track().await;
357+
}
358+
StemPressType::TriplePress => {
359+
info!("Triple press detected, going to previous track");
360+
controller.previous_track().await;
361+
}
362+
_ => {
363+
debug!("Unhandled stem press type: {:?}", press_type);
364+
}
365+
}
366+
} else {
367+
debug!("Stem control disabled, ignoring stem press event");
368+
}
369+
}
328370
_ => {
329371
debug!("Received unhandled AACP event: {:?}", event);
330372
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(

linux-rust/src/main.rs

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::bluetooth::managers::DeviceManagers;
1010
use crate::devices::enums::DeviceData;
1111
use crate::ui::messages::BluetoothUIMessage;
1212
use crate::ui::tray::MyTray;
13-
use crate::utils::get_devices_path;
13+
use crate::utils::{get_app_settings_path, get_devices_path};
1414
use bluer::{Address, InternalErrorKind};
1515
use clap::Parser;
1616
use dbus::arg::{RefArg, Variant};
@@ -19,9 +19,10 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::Properties;
1919
use dbus::message::MatchRule;
2020
use devices::airpods::AirPodsDevice;
2121
use ksni::TrayMethods;
22-
use log::info;
22+
use log::{info, warn};
2323
use std::collections::HashMap;
2424
use std::env;
25+
use std::sync::atomic::{AtomicBool, Ordering};
2526
use std::sync::Arc;
2627
use tokio::sync::RwLock;
2728
use tokio::sync::mpsc::unbounded_channel;
@@ -44,6 +45,11 @@ struct Args {
4445
le_debug: bool,
4546
#[arg(long, short = 'v', help = "Show application version and exit")]
4647
version: bool,
48+
#[arg(
49+
long,
50+
help = "Disable stem press track control (use this if your environment already handles AirPods AVRCP commands natively)"
51+
)]
52+
no_stem_control: bool,
4753
}
4854

4955
fn main() -> iced::Result {
@@ -59,10 +65,10 @@ fn main() -> iced::Result {
5965

6066
let log_level = if args.debug { "debug" } else { "info" };
6167
let wayland_display = env::var("WAYLAND_DISPLAY").is_ok();
68+
if wayland_display && env::var("WGPU_BACKEND").is_err() {
69+
unsafe { env::set_var("WGPU_BACKEND", "gl") };
70+
}
6271
if env::var("RUST_LOG").is_err() {
63-
if wayland_display {
64-
unsafe { env::set_var("WGPU_BACKEND", "gl") };
65-
}
6672
unsafe {
6773
env::set_var(
6874
"RUST_LOG",
@@ -80,19 +86,42 @@ fn main() -> iced::Result {
8086

8187
let device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>> =
8288
Arc::new(RwLock::new(HashMap::new()));
83-
let device_managers_clone = device_managers.clone();
84-
std::thread::spawn(|| {
89+
90+
// Load stem_control initial value from settings JSON, then apply CLI override.
91+
let app_settings_path = get_app_settings_path();
92+
let saved_stem_control = std::fs::read_to_string(&app_settings_path)
93+
.ok()
94+
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
95+
.and_then(|v| v.get("stem_control").and_then(|b| b.as_bool()))
96+
.unwrap_or(true);
97+
// CLI --no-stem-control overrides the saved setting.
98+
let stem_control_initial = if args.no_stem_control { false } else { saved_stem_control };
99+
let stem_control: Arc<AtomicBool> = Arc::new(AtomicBool::new(stem_control_initial));
100+
101+
if args.no_tray {
102+
// Run headless without UI
103+
info!("Running in headless mode (no GUI)");
85104
let rt = tokio::runtime::Runtime::new().unwrap();
86-
rt.block_on(async_main(ui_tx, device_managers_clone))
87-
.unwrap();
88-
});
105+
rt.block_on(async_main(ui_tx, device_managers, stem_control)).unwrap();
106+
Ok(())
107+
} else {
108+
// Run with UI
109+
let device_managers_clone = device_managers.clone();
110+
let stem_control_clone = stem_control.clone();
111+
std::thread::spawn(|| {
112+
let rt = tokio::runtime::Runtime::new().unwrap();
113+
rt.block_on(async_main(ui_tx, device_managers_clone, stem_control_clone))
114+
.unwrap();
115+
});
89116

90-
ui::window::start_ui(ui_rx, args.start_minimized, device_managers)
117+
ui::window::start_ui(ui_rx, args.start_minimized, device_managers, stem_control)
118+
}
91119
}
92120

93121
async fn async_main(
94122
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
95123
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
124+
stem_control: Arc<AtomicBool>,
96125
) -> bluer::Result<()> {
97126
let args = Args::parse();
98127

@@ -160,7 +189,7 @@ async fn async_main(
160189
.unwrap_or_else(|| "Unknown".to_string());
161190
info!("Found connected AirPods: {}, initializing.", name);
162191
let airpods_device =
163-
AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone()).await;
192+
AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone(), stem_control.clone()).await;
164193

165194
let mut managers = device_managers.write().await;
166195
// let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
@@ -170,11 +199,11 @@ async fn async_main(
170199
.or_insert(dev_managers)
171200
.set_aacp(airpods_device.aacp_manager);
172201
drop(managers);
173-
ui_tx
174-
.send(BluetoothUIMessage::DeviceConnected(
175-
device.address().to_string(),
176-
))
177-
.unwrap();
202+
if let Err(e) = ui_tx.send(BluetoothUIMessage::DeviceConnected(
203+
device.address().to_string(),
204+
)) {
205+
warn!("Failed to send DeviceConnected UI message: {:?}", e);
206+
}
178207
}
179208
Err(_) => {
180209
info!("No connected AirPods found.");
@@ -205,9 +234,9 @@ async fn async_main(
205234
.entry(addr_str.clone())
206235
.or_insert(dev_managers)
207236
.set_att(dev.att_manager);
208-
ui_tx_clone
209-
.send(BluetoothUIMessage::DeviceConnected(addr_str))
210-
.unwrap();
237+
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str)) {
238+
warn!("Failed to send DeviceConnected UI message: {:?}", e);
239+
}
211240
}
212241
drop(managers)
213242
});
@@ -280,9 +309,9 @@ async fn async_main(
280309
.or_insert(dev_managers)
281310
.set_att(dev.att_manager);
282311
drop(managers);
283-
ui_tx_clone
284-
.send(BluetoothUIMessage::DeviceConnected(addr_str.clone()))
285-
.unwrap();
312+
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) {
313+
warn!("Failed to send DeviceConnected UI message: {:?}", e);
314+
}
286315
});
287316
}
288317
return true;
@@ -298,8 +327,9 @@ async fn async_main(
298327
let handle_clone = tray_handle.clone();
299328
let ui_tx_clone = ui_tx.clone();
300329
let device_managers = device_managers.clone();
330+
let stem_control_arc = stem_control.clone();
301331
tokio::spawn(async move {
302-
let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone()).await;
332+
let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone(), stem_control_arc.clone()).await;
303333
let mut managers = device_managers.write().await;
304334
// let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
305335
let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone());
@@ -308,9 +338,9 @@ async fn async_main(
308338
.or_insert(dev_managers)
309339
.set_aacp(airpods_device.aacp_manager);
310340
drop(managers);
311-
ui_tx_clone
312-
.send(BluetoothUIMessage::DeviceConnected(addr_str.clone()))
313-
.unwrap();
341+
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) {
342+
warn!("Failed to send DeviceConnected UI message: {:?}", e);
343+
}
314344
});
315345
true
316346
})?;

0 commit comments

Comments
 (0)