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)
This commit is contained in:
Andrey
2026-03-31 00:02:20 -04:00
committed by GitHub
parent decf070f9f
commit a0cdbb2842
5 changed files with 374 additions and 33 deletions

View File

@@ -200,6 +200,18 @@ pub enum StemPressType {
LongPress = 0x08,
}
impl StemPressType {
fn from_u8(value: u8) -> Option<Self> {
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<Self> {
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<ConnectedDevice>, Vec<ConnectedDevice>),
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));

View File

@@ -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<Handle<MyTray>>,
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
stem_control: Arc<AtomicBool>,
) -> 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 (0x050x08)
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(

View File

@@ -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<RwLock<HashMap<String, DeviceManagers>>> =
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::<serde_json::Value>(&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<AtomicBool> = 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<BluetoothUIMessage>,
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
stem_control: Arc<AtomicBool>,
) -> 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
})?;

View File

@@ -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<String>,) = 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::<String>("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<String>,) = 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::<String>("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;

View File

@@ -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<BluetoothUIMessage>,
start_minimized: bool,
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
stem_control: Arc<AtomicBool>,
) -> 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<DeviceType>,
selected_device_type: Option<DeviceType>,
tray_text_mode: bool,
stem_control: Arc<AtomicBool>,
}
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<BluetoothUIMessage>,
start_minimized: bool,
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
stem_control: Arc<AtomicBool>,
) -> (Self, Task<Message>) {
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)