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

@@ -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(