diff --git a/linux-rust/src/bluetooth/le.rs b/linux-rust/src/bluetooth/le.rs index 69918bf3a..c38e5f75b 100644 --- a/linux-rust/src/bluetooth/le.rs +++ b/linux-rust/src/bluetooth/le.rs @@ -1,5 +1,6 @@ -use crate::bluetooth::aacp::BatteryStatus; +use crate::bluetooth::aacp::{AACPEvent, BatteryComponent, BatteryInfo, BatteryStatus}; use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; +use crate::ui::messages::BluetoothUIMessage; use crate::ui::tray::MyTray; use crate::utils::{ah, get_devices_path, get_preferences_path}; use aes::Aes128; @@ -15,6 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::sync::mpsc::UnboundedSender; fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let cipher = Aes128::new(&Array::from(*key)); @@ -46,7 +48,10 @@ fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool { hash == computed_hash } -pub async fn start_le_monitor(tray_handle: Option>) -> bluer::Result<()> { +pub async fn start_le_monitor( + tray_handle: Option>, + ui_tx: UnboundedSender, +) -> bluer::Result<()> { let session = Session::new().await?; let adapter = session.default_adapter().await?; adapter.set_powered(true).await?; @@ -143,6 +148,7 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue if matched_airpods_mac.is_some() { let mut events = dev.events().await?; let tray_handle_clone = tray_handle.clone(); + let ui_tx_clone = ui_tx.clone(); let connecting_macs_clone = Arc::clone(&connecting_macs); tokio::spawn(async move { while let Some(ev) = events.next().await { @@ -246,6 +252,7 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue ); } } + } else { info!( "Auto-connect is disabled for {}, not attempting to connect.", matched_airpods_mac.as_ref().unwrap() @@ -336,6 +343,36 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue .await; } + // The tray is not the only consumer: the window shows the + // case level too, and over AACP the case reports itself as + // disconnected whenever the buds are outside it. + if let Some(mac) = matched_airpods_mac.as_ref() { + let battery_info = [ + (BatteryComponent::Left, left_byte, left_battery, left_charging), + (BatteryComponent::Right, right_byte, right_battery, right_charging), + (BatteryComponent::Case, case_byte, case_battery, case_charging), + ] + .into_iter() + .filter(|(_, raw, _, _)| *raw != 0xff) + .map(|(component, _, level, charging)| BatteryInfo { + component, + level: level as u8, + status: if charging { + BatteryStatus::Charging + } else { + BatteryStatus::NotCharging + }, + }) + .collect::>(); + + if !battery_info.is_empty() { + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( + mac.clone(), + AACPEvent::BatteryInfo(battery_info), + )); + } + } + debug!( "Battery status: Left: {}, Right: {}, Case: {}, InEar: L:{} R:{}", if left_byte == 0xff { diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs index f0e876cf0..5d42a88bd 100644 --- a/linux-rust/src/devices/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -142,6 +142,15 @@ impl AirPodsDevice { } }); + // PipeWire can leave the card on the "off" profile when it appears + // before its A2DP transport is ready, and nothing revisits that choice + // until playback starts - so freshly connected buds stay silent, and + // the microphone has no transport either. Claim a profile right away. + let mc_profile = media_controller.clone(); + tokio::spawn(async move { + mc_profile.lock().await.activate_a2dp_profile().await; + }); + let mc_listener = media_controller.lock().await; let aacp_manager_clone_listener = aacp_manager.clone(); mc_listener diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index f43f575b2..8b2d80897 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -87,15 +87,20 @@ fn main() -> iced::Result { // 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)).unwrap(); + if let Err(e) = rt.block_on(async_main(ui_tx, device_managers)) { + log::error!("LibrePods could not start: {e}"); + std::process::exit(1); + } Ok(()) } else { // Run with UI 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(); + if let Err(e) = rt.block_on(async_main(ui_tx, device_managers_clone)) { + log::error!("LibrePods could not start: {e}"); + std::process::exit(1); + } }); ui::window::start_ui(ui_rx, args.start_minimized, device_managers) @@ -149,14 +154,30 @@ async fn async_main( Some(handle) }; - let session = bluer::Session::new().await?; - let adapter = session.default_adapter().await?; - adapter.set_powered(true).await?; + let session = bluer::Session::new().await.inspect_err(|e| { + log::error!( + "Cannot talk to BlueZ over D-Bus: {e}. Is the bluetooth service running? \ + Check with `systemctl status bluetooth`." + ) + })?; + let adapter = session.default_adapter().await.inspect_err(|e| { + log::error!( + "No Bluetooth adapter available: {e}. Make sure an adapter is present \ + and not blocked - see `rfkill list bluetooth`." + ) + })?; + adapter.set_powered(true).await.inspect_err(|e| { + log::error!( + "Cannot power on the Bluetooth adapter: {e}. It is likely soft-blocked, \ + try `rfkill unblock bluetooth`." + ) + })?; let le_tray_clone = tray_handle.clone(); + let le_ui_tx = ui_tx.clone(); tokio::spawn(async move { info!("Starting LE monitor..."); - if let Err(e) = start_le_monitor(le_tray_clone).await { + if let Err(e) = start_le_monitor(le_tray_clone, le_ui_tx).await { log::error!("LE monitor error: {}", e); } }); diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 5bd68b1a2..5a80a6d5f 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -29,6 +29,7 @@ struct OwnedCardInfo { index: u32, proplist: Proplist, profiles: Vec, + active_profile: Option, } #[derive(Clone, Debug)] @@ -301,75 +302,133 @@ impl MediaController { } } + /// Number of one-second attempts to wait for A2DP profiles to be enumerated. + const A2DP_ENUMERATION_ATTEMPTS: u32 = 5; + + /// Currently active profile of the card, used to avoid pointless switches. + async fn get_active_profile(&self, card_index: u32) -> Option { + tokio::task::spawn_blocking(move || { + get_card_info_list_sync() + .iter() + .find(|c| c.index == card_index) + .and_then(|c| c.active_profile.clone()) + }) + .await + .unwrap_or(None) + } + + /// Resolves the card by Bluetooth MAC and refreshes the cached index. + /// PipeWire can assign a different index after a disconnect/reconnect. + async fn refresh_device_index(&self) -> Option { + let mac = self.state.lock().await.connected_device_mac.clone(); + if mac.is_empty() { + return None; + } + let index = self.get_audio_device_index(&mac).await; + self.state.lock().await.device_index = index; + index + } + + /// Waits for the card to expose an A2DP profile, re-resolving the card on + /// every attempt. Sleeps between attempts, but never after the last one. + async fn wait_for_a2dp_profile(&self, attempts: u32) -> bool { + for attempt in 0..attempts { + if self.refresh_device_index().await.is_some() && self.is_a2dp_profile_available().await + { + return true; + } + if attempt + 1 < attempts { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + false + } + + async fn restart_wire_plumber(&self) -> bool { + info!("Restarting WirePlumber to rediscover A2DP profiles"); + let result = Command::new("systemctl") + .args(["--user", "restart", "wireplumber"]) + .output(); + + match result { + Ok(output) if output.status.success() => { + info!("WirePlumber restarted successfully"); + tokio::time::sleep(Duration::from_secs(2)).await; + true + } + _ => { + error!("Failed to restart WirePlumber. Do you use wireplumber?"); + false + } + } + } + pub async fn activate_a2dp_profile(&self) { debug!("Entering activate_a2dp_profile"); - let state = self.state.lock().await; - if state.connected_device_mac.is_empty() { + if self.state.lock().await.connected_device_mac.is_empty() { warn!("Connected device MAC is empty, cannot activate A2DP profile"); return; } - let device_index = state.device_index; - let mac = state.connected_device_mac.clone(); - drop(state); - - let mut current_device_index = device_index; - - if current_device_index.is_none() { - warn!("Device index not found, trying to get it."); - current_device_index = self.get_audio_device_index(&mac).await; - if let Some(idx) = current_device_index { - self.state.lock().await.device_index = Some(idx); - } else { - warn!("Could not get device index. Cannot activate A2DP profile."); - return; - } + // Always resolve the card by MAC first: a reconnect can change its index. + if self.refresh_device_index().await.is_none() { + warn!("Could not get device index. Cannot activate A2DP profile."); + return; } if !self.is_a2dp_profile_available().await { - warn!("A2DP profile not available, attempting to restart WirePlumber"); - if self.restart_wire_plumber().await { - let mut state = self.state.lock().await; - state.device_index = self - .get_audio_device_index(&state.connected_device_mac) - .await; - debug!( - "Updated device_index after WirePlumber restart: {:?}", - state.device_index - ); - if !self.is_a2dp_profile_available().await { - error!("A2DP profile still not available after WirePlumber restart"); + // A freshly connected card can show up before its profiles are + // enumerated. Give that a grace period before restarting + // WirePlumber, which interrupts playback for several seconds. + warn!("A2DP profile not available yet, waiting for enumeration"); + if !self + .wait_for_a2dp_profile(Self::A2DP_ENUMERATION_ATTEMPTS) + .await + { + warn!("A2DP profile still missing, restarting WirePlumber as a last resort"); + if !self.restart_wire_plumber().await + || !self + .wait_for_a2dp_profile(Self::A2DP_ENUMERATION_ATTEMPTS) + .await + { + error!("A2DP profile unavailable, skipping profile activation"); return; } - } else { - error!("Could not restart WirePlumber, A2DP profile unavailable"); - return; } } + let Some(idx) = self.state.lock().await.device_index else { + error!("Device index not available for activating profile."); + return; + }; + + // Leave an already-active A2DP variant alone. Switching profiles + // recreates the PipeWire sink and stops the stream that triggered this + // activation, so players pause again right after the user pressed play. + if let Some(active) = self.get_active_profile(idx).await + && active.starts_with("a2dp") + { + debug!("A2DP profile {} already active, leaving it unchanged", active); + return; + } + let preferred_profile = self.get_preferred_a2dp_profile().await; if preferred_profile.is_empty() { error!("No suitable A2DP profile found"); return; } - let device_index = self.state.lock().await.device_index; - if let Some(idx) = device_index { - info!("Activating A2DP profile for AirPods: {}", preferred_profile); - let profile_name = preferred_profile.clone(); - let success = - tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name)) - .await - .unwrap_or(false); - - if success { - info!("Successfully activated A2DP profile: {}", preferred_profile); - } else { - warn!("Failed to activate A2DP profile: {}", preferred_profile); - } + info!("Activating A2DP profile for AirPods: {}", preferred_profile); + let profile_name = preferred_profile.clone(); + let success = tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name)) + .await + .unwrap_or(false); + + if success { + info!("Successfully activated A2DP profile: {}", preferred_profile); } else { - error!("Device index not available for activating profile."); + warn!("Failed to activate A2DP profile: {}", preferred_profile); } } @@ -564,7 +623,7 @@ impl MediaController { return cached_profile; } - for profile in ["a2dp-sink-sbc_xq", "a2dp-sink-sbc", "a2dp-sink"] { + for profile in crate::utils::load_preferred_codec().profile_order() { if self.is_profile_available(index, profile).await { info!("Selected best available A2DP profile: {}", profile); self.state.lock().await.cached_a2dp_profile = profile.to_string(); @@ -594,25 +653,6 @@ impl MediaController { .unwrap_or(false) } - async fn restart_wire_plumber(&self) -> bool { - info!("Restarting WirePlumber to rediscover A2DP profiles"); - let result = Command::new("systemctl") - .args(["--user", "restart", "wireplumber"]) - .output(); - - match result { - Ok(output) if output.status.success() => { - info!("WirePlumber restarted successfully"); - tokio::time::sleep(Duration::from_secs(2)).await; - true - } - _ => { - error!("Failed to restart WirePlumber. Do you use wireplumber?"); - false - } - } - } - async fn get_audio_device_index(&self, mac: &str) -> Option { if mac.is_empty() { return None; @@ -920,10 +960,15 @@ fn get_card_info_list_sync() -> Vec { name: p.name.as_ref().map(|n| n.to_string()), }) .collect(); + let active_profile = item + .active_profile + .as_ref() + .and_then(|p| p.name.as_ref().map(|n| n.to_string())); cards.borrow_mut().push(OwnedCardInfo { index: item.index, proplist: item.proplist.clone(), profiles, + active_profile, }); } } diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..82f221060 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -9,14 +9,14 @@ use crate::devices::enums::{ use crate::ui::airpods::airpods_view; use crate::ui::messages::BluetoothUIMessage; use crate::ui::nothing::nothing_view; -use crate::utils::{MyTheme, get_app_settings_path, get_devices_path}; +use crate::utils::{MyTheme, PreferredCodec, get_app_settings_path, get_devices_path}; use bluer::{Address}; use iced::border::Radius; use iced::overlay::menu; use iced::widget::button::Style; use iced::widget::rule::FillMode; use iced::widget::{ - Space, button, column, combo_box, container, pane_grid, row, rule, scrollable, text, + Space, button, column, combo_box, container, pane_grid, pick_list, row, rule, scrollable, text, text_input, toggler }; use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings, Program}; @@ -76,6 +76,7 @@ pub struct App { selected_device_type: Option, tray_text_mode: bool, stem_control: bool, + preferred_codec: PreferredCodec, } pub struct BluetoothState { @@ -108,6 +109,7 @@ pub enum Message { StateChanged(String, DeviceState), TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy StemControlChanged(bool), + PreferredCodecChanged(PreferredCodec), } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -166,6 +168,11 @@ impl App { .and_then(|v| v.get("stem_control").cloned()) .and_then(|s| serde_json::from_value(s).ok()) .unwrap_or(false); + let preferred_codec = settings + .clone() + .and_then(|v| v.get("preferred_codec").cloned()) + .and_then(|c| serde_json::from_value(c).ok()) + .unwrap_or_default(); let bluetooth_state = BluetoothState::new(); @@ -217,6 +224,7 @@ impl App { device_managers, tray_text_mode, stem_control, + preferred_codec, }, Task::batch(vec![open_task, wait_task]), ) @@ -253,6 +261,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -631,6 +640,24 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, + }); + debug!( + "Writing settings to {}: {}", + app_settings_path.to_str().unwrap(), + settings + ); + std::fs::write(app_settings_path, settings.to_string()).ok(); + Task::none() + } + Message::PreferredCodecChanged(codec) => { + self.preferred_codec = codec; + 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, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -647,6 +674,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -927,6 +955,45 @@ impl App { } } Tab::Settings => { + let preferred_codec_picker = container( + row![ + column![ + text("Preferred audio codec").size(16), + text("Codec to activate for playback. The others are used as fallbacks if the chosen one is unavailable.").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), + pick_list( + PreferredCodec::ALL, + Some(self.preferred_codec), + Message::PreferredCodecChanged, + ) + ] + .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 tray_text_mode_toggle = container( row![ column![ @@ -1096,6 +1163,26 @@ impl App { ) .align_y(Center); + let audio_settings_col = column![ + container( + text("Audio").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, + }), + preferred_codec_picker + ] + .spacing(12); + let controls_settings_col = column![ container( text("Controls").size(20).style( @@ -1123,6 +1210,8 @@ impl App { tray_text_mode_toggle, Space::new().height(Length::from(20)), controls_settings_col, + Space::new().height(Length::from(20)), + audio_settings_col, ] ) .padding(20) diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 88ee466a8..697e933a6 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -51,6 +51,60 @@ pub fn get_app_settings_path() -> PathBuf { new_path } +/// Preferred A2DP codec. The chosen one is tried first, the rest act as fallbacks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum PreferredCodec { + #[default] + Aac, + SbcXq, + Sbc, +} + +impl PreferredCodec { + pub const ALL: [PreferredCodec; 3] = [Self::Aac, Self::SbcXq, Self::Sbc]; + + /// PulseAudio/PipeWire card profile name for this codec. + pub fn profile_name(self) -> &'static str { + match self { + Self::Aac => "a2dp-sink", + Self::SbcXq => "a2dp-sink-sbc_xq", + Self::Sbc => "a2dp-sink-sbc", + } + } + + /// Profiles to try, most preferred first. + pub fn profile_order(self) -> Vec<&'static str> { + std::iter::once(self.profile_name()) + .chain( + Self::ALL + .iter() + .filter(|c| **c != self) + .map(|c| c.profile_name()), + ) + .collect() + } +} + +impl std::fmt::Display for PreferredCodec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Aac => "AAC", + Self::SbcXq => "SBC-XQ", + Self::Sbc => "SBC", + }) + } +} + +/// Reads the preferred codec from the app settings file, falling back to AAC. +pub fn load_preferred_codec() -> PreferredCodec { + std::fs::read_to_string(get_app_settings_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("preferred_codec").cloned()) + .and_then(|c| serde_json::from_value(c).ok()) + .unwrap_or_default() +} + fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let mut swapped_key = *key; swapped_key.reverse();