mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-11 13:57:22 +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,
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -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 (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(
|
||||
|
||||
@@ -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
|
||||
})?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user