mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-30 10:05:28 +00:00
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:
@@ -200,6 +200,18 @@ pub enum StemPressType {
|
|||||||
LongPress = 0x08,
|
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)]
|
#[repr(u8)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum StemPressBudType {
|
pub enum StemPressBudType {
|
||||||
@@ -207,6 +219,16 @@ pub enum StemPressBudType {
|
|||||||
Right = 0x02,
|
Right = 0x02,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl StemPressBudType {
|
||||||
|
fn from_u8(value: u8) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
0x01 => Some(Self::Left),
|
||||||
|
0x02 => Some(Self::Right),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AudioSourceType {
|
pub enum AudioSourceType {
|
||||||
@@ -283,6 +305,7 @@ pub enum AACPEvent {
|
|||||||
AudioSource(AudioSource),
|
AudioSource(AudioSource),
|
||||||
ConnectedDevices(Vec<ConnectedDevice>, Vec<ConnectedDevice>),
|
ConnectedDevices(Vec<ConnectedDevice>, Vec<ConnectedDevice>),
|
||||||
OwnershipToFalseRequest,
|
OwnershipToFalseRequest,
|
||||||
|
StemPress(StemPressType, StemPressBudType),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -795,7 +818,26 @@ impl AACPManager {
|
|||||||
error!("Failed to save devices: {}", e);
|
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 => {
|
opcodes::AUDIO_SOURCE => {
|
||||||
if payload.len() < 9 {
|
if payload.len() < 9 {
|
||||||
error!("Audio Source packet too short: {}", hex::encode(payload));
|
error!("Audio Source packet too short: {}", hex::encode(payload));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use ksni::Handle;
|
|||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ impl AirPodsDevice {
|
|||||||
mac_address: Address,
|
mac_address: Address,
|
||||||
tray_handle: Option<Handle<MyTray>>,
|
tray_handle: Option<Handle<MyTray>>,
|
||||||
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
|
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
|
||||||
|
stem_control: Arc<AtomicBool>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
info!("Creating new AirPodsDevice for {}", mac_address);
|
info!("Creating new AirPodsDevice for {}", mac_address);
|
||||||
let mut aacp_manager = AACPManager::new();
|
let mut aacp_manager = AACPManager::new();
|
||||||
@@ -80,6 +82,20 @@ impl AirPodsDevice {
|
|||||||
error!("Failed to request proximity keys: {}", e);
|
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()
|
let session = bluer::Session::new()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to get bluer session");
|
.expect("Failed to get bluer session");
|
||||||
@@ -206,6 +222,7 @@ impl AirPodsDevice {
|
|||||||
let local_mac_events = local_mac.clone();
|
let local_mac_events = local_mac.clone();
|
||||||
let ui_tx_clone = ui_tx.clone();
|
let ui_tx_clone = ui_tx.clone();
|
||||||
let command_tx_clone = command_tx.clone();
|
let command_tx_clone = command_tx.clone();
|
||||||
|
let stem_control_clone = stem_control.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
let event_clone = event.clone();
|
let event_clone = event.clone();
|
||||||
@@ -325,6 +342,31 @@ impl AirPodsDevice {
|
|||||||
controller.pause_all_media().await;
|
controller.pause_all_media().await;
|
||||||
controller.deactivate_a2dp_profile().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);
|
debug!("Received unhandled AACP event: {:?}", event);
|
||||||
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
|
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::bluetooth::managers::DeviceManagers;
|
|||||||
use crate::devices::enums::DeviceData;
|
use crate::devices::enums::DeviceData;
|
||||||
use crate::ui::messages::BluetoothUIMessage;
|
use crate::ui::messages::BluetoothUIMessage;
|
||||||
use crate::ui::tray::MyTray;
|
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 bluer::{Address, InternalErrorKind};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use dbus::arg::{RefArg, Variant};
|
use dbus::arg::{RefArg, Variant};
|
||||||
@@ -19,9 +19,10 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::Properties;
|
|||||||
use dbus::message::MatchRule;
|
use dbus::message::MatchRule;
|
||||||
use devices::airpods::AirPodsDevice;
|
use devices::airpods::AirPodsDevice;
|
||||||
use ksni::TrayMethods;
|
use ksni::TrayMethods;
|
||||||
use log::info;
|
use log::{info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
@@ -44,6 +45,11 @@ struct Args {
|
|||||||
le_debug: bool,
|
le_debug: bool,
|
||||||
#[arg(long, short = 'v', help = "Show application version and exit")]
|
#[arg(long, short = 'v', help = "Show application version and exit")]
|
||||||
version: bool,
|
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 {
|
fn main() -> iced::Result {
|
||||||
@@ -59,10 +65,10 @@ fn main() -> iced::Result {
|
|||||||
|
|
||||||
let log_level = if args.debug { "debug" } else { "info" };
|
let log_level = if args.debug { "debug" } else { "info" };
|
||||||
let wayland_display = env::var("WAYLAND_DISPLAY").is_ok();
|
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 env::var("RUST_LOG").is_err() {
|
||||||
if wayland_display {
|
|
||||||
unsafe { env::set_var("WGPU_BACKEND", "gl") };
|
|
||||||
}
|
|
||||||
unsafe {
|
unsafe {
|
||||||
env::set_var(
|
env::set_var(
|
||||||
"RUST_LOG",
|
"RUST_LOG",
|
||||||
@@ -80,19 +86,42 @@ fn main() -> iced::Result {
|
|||||||
|
|
||||||
let device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>> =
|
let device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>> =
|
||||||
Arc::new(RwLock::new(HashMap::new()));
|
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(
|
async fn async_main(
|
||||||
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
|
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
|
||||||
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
||||||
|
stem_control: Arc<AtomicBool>,
|
||||||
) -> bluer::Result<()> {
|
) -> bluer::Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
@@ -160,7 +189,7 @@ async fn async_main(
|
|||||||
.unwrap_or_else(|| "Unknown".to_string());
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
info!("Found connected AirPods: {}, initializing.", name);
|
info!("Found connected AirPods: {}, initializing.", name);
|
||||||
let airpods_device =
|
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 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_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
|
||||||
@@ -170,11 +199,11 @@ async fn async_main(
|
|||||||
.or_insert(dev_managers)
|
.or_insert(dev_managers)
|
||||||
.set_aacp(airpods_device.aacp_manager);
|
.set_aacp(airpods_device.aacp_manager);
|
||||||
drop(managers);
|
drop(managers);
|
||||||
ui_tx
|
if let Err(e) = ui_tx.send(BluetoothUIMessage::DeviceConnected(
|
||||||
.send(BluetoothUIMessage::DeviceConnected(
|
device.address().to_string(),
|
||||||
device.address().to_string(),
|
)) {
|
||||||
))
|
warn!("Failed to send DeviceConnected UI message: {:?}", e);
|
||||||
.unwrap();
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
info!("No connected AirPods found.");
|
info!("No connected AirPods found.");
|
||||||
@@ -205,9 +234,9 @@ async fn async_main(
|
|||||||
.entry(addr_str.clone())
|
.entry(addr_str.clone())
|
||||||
.or_insert(dev_managers)
|
.or_insert(dev_managers)
|
||||||
.set_att(dev.att_manager);
|
.set_att(dev.att_manager);
|
||||||
ui_tx_clone
|
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str)) {
|
||||||
.send(BluetoothUIMessage::DeviceConnected(addr_str))
|
warn!("Failed to send DeviceConnected UI message: {:?}", e);
|
||||||
.unwrap();
|
}
|
||||||
}
|
}
|
||||||
drop(managers)
|
drop(managers)
|
||||||
});
|
});
|
||||||
@@ -280,9 +309,9 @@ async fn async_main(
|
|||||||
.or_insert(dev_managers)
|
.or_insert(dev_managers)
|
||||||
.set_att(dev.att_manager);
|
.set_att(dev.att_manager);
|
||||||
drop(managers);
|
drop(managers);
|
||||||
ui_tx_clone
|
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) {
|
||||||
.send(BluetoothUIMessage::DeviceConnected(addr_str.clone()))
|
warn!("Failed to send DeviceConnected UI message: {:?}", e);
|
||||||
.unwrap();
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -298,8 +327,9 @@ async fn async_main(
|
|||||||
let handle_clone = tray_handle.clone();
|
let handle_clone = tray_handle.clone();
|
||||||
let ui_tx_clone = ui_tx.clone();
|
let ui_tx_clone = ui_tx.clone();
|
||||||
let device_managers = device_managers.clone();
|
let device_managers = device_managers.clone();
|
||||||
|
let stem_control_arc = stem_control.clone();
|
||||||
tokio::spawn(async move {
|
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 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_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
|
||||||
let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_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)
|
.or_insert(dev_managers)
|
||||||
.set_aacp(airpods_device.aacp_manager);
|
.set_aacp(airpods_device.aacp_manager);
|
||||||
drop(managers);
|
drop(managers);
|
||||||
ui_tx_clone
|
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) {
|
||||||
.send(BluetoothUIMessage::DeviceConnected(addr_str.clone()))
|
warn!("Failed to send DeviceConnected UI message: {:?}", e);
|
||||||
.unwrap();
|
}
|
||||||
});
|
});
|
||||||
true
|
true
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -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 {
|
async fn is_a2dp_profile_available(&self) -> bool {
|
||||||
debug!("Entering is_a2dp_profile_available");
|
debug!("Entering is_a2dp_profile_available");
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use iced::{
|
|||||||
};
|
};
|
||||||
use log::{debug, error};
|
use log::{debug, error};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc::UnboundedReceiver;
|
use tokio::sync::mpsc::UnboundedReceiver;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
@@ -33,13 +34,14 @@ pub fn start_ui(
|
|||||||
ui_rx: UnboundedReceiver<BluetoothUIMessage>,
|
ui_rx: UnboundedReceiver<BluetoothUIMessage>,
|
||||||
start_minimized: bool,
|
start_minimized: bool,
|
||||||
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
||||||
|
stem_control: Arc<AtomicBool>,
|
||||||
) -> iced::Result {
|
) -> iced::Result {
|
||||||
daemon(App::title, App::update, App::view)
|
daemon(App::title, App::update, App::view)
|
||||||
.subscription(App::subscription)
|
.subscription(App::subscription)
|
||||||
.theme(App::theme)
|
.theme(App::theme)
|
||||||
.font(include_bytes!("../../assets/font/sf_pro.otf").as_slice())
|
.font(include_bytes!("../../assets/font/sf_pro.otf").as_slice())
|
||||||
.default_font(Font::with_name("SF Pro Text"))
|
.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 {
|
pub struct App {
|
||||||
@@ -57,6 +59,7 @@ pub struct App {
|
|||||||
device_type_state: combo_box::State<DeviceType>,
|
device_type_state: combo_box::State<DeviceType>,
|
||||||
selected_device_type: Option<DeviceType>,
|
selected_device_type: Option<DeviceType>,
|
||||||
tray_text_mode: bool,
|
tray_text_mode: bool,
|
||||||
|
stem_control: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct BluetoothState {
|
pub struct BluetoothState {
|
||||||
@@ -88,6 +91,7 @@ pub enum Message {
|
|||||||
CancelAddDevice,
|
CancelAddDevice,
|
||||||
StateChanged(String, DeviceState),
|
StateChanged(String, DeviceState),
|
||||||
TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
@@ -108,6 +112,7 @@ impl App {
|
|||||||
ui_rx: UnboundedReceiver<BluetoothUIMessage>,
|
ui_rx: UnboundedReceiver<BluetoothUIMessage>,
|
||||||
start_minimized: bool,
|
start_minimized: bool,
|
||||||
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
||||||
|
stem_control: Arc<AtomicBool>,
|
||||||
) -> (Self, Task<Message>) {
|
) -> (Self, Task<Message>) {
|
||||||
let (mut panes, first_pane) = pane_grid::State::new(Pane::Sidebar);
|
let (mut panes, first_pane) = pane_grid::State::new(Pane::Sidebar);
|
||||||
let split = panes.split(pane_grid::Axis::Vertical, first_pane, Pane::Content);
|
let split = panes.split(pane_grid::Axis::Vertical, first_pane, Pane::Content);
|
||||||
@@ -191,6 +196,7 @@ impl App {
|
|||||||
selected_device_type: None,
|
selected_device_type: None,
|
||||||
device_managers,
|
device_managers,
|
||||||
tray_text_mode,
|
tray_text_mode,
|
||||||
|
stem_control,
|
||||||
},
|
},
|
||||||
Task::batch(vec![open_task, wait_task]),
|
Task::batch(vec![open_task, wait_task]),
|
||||||
)
|
)
|
||||||
@@ -223,7 +229,11 @@ impl App {
|
|||||||
Message::ThemeSelected(theme) => {
|
Message::ThemeSelected(theme) => {
|
||||||
self.selected_theme = theme;
|
self.selected_theme = theme;
|
||||||
let app_settings_path = get_app_settings_path();
|
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!(
|
debug!(
|
||||||
"Writing settings to {}: {}",
|
"Writing settings to {}: {}",
|
||||||
app_settings_path.to_str().unwrap(),
|
app_settings_path.to_str().unwrap(),
|
||||||
@@ -588,7 +598,27 @@ impl App {
|
|||||||
Message::TrayTextModeChanged(is_enabled) => {
|
Message::TrayTextModeChanged(is_enabled) => {
|
||||||
self.tray_text_mode = is_enabled;
|
self.tray_text_mode = is_enabled;
|
||||||
let app_settings_path = get_app_settings_path();
|
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!(
|
debug!(
|
||||||
"Writing settings to {}: {}",
|
"Writing settings to {}: {}",
|
||||||
app_settings_path.to_str().unwrap(),
|
app_settings_path.to_str().unwrap(),
|
||||||
@@ -994,11 +1024,74 @@ impl App {
|
|||||||
]
|
]
|
||||||
.spacing(12);
|
.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(
|
container(
|
||||||
column![
|
column![
|
||||||
appearance_settings_col,
|
appearance_settings_col,
|
||||||
Space::with_height(Length::from(20)),
|
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)
|
.padding(20)
|
||||||
|
|||||||
Reference in New Issue
Block a user