From 2cd378b79d6a90d4d7b1be7f0cc0b7d7875caef3 Mon Sep 17 00:00:00 2001 From: Ramfi Aogusto Date: Tue, 11 Aug 2026 23:35:15 -0400 Subject: [PATCH 1/2] fix(linux): wait for the audio card to reappear after a takeover Taking the connection back from another device makes the earbuds renegotiate, and the card drops out of the sound server for a moment while that happens. get_audio_device_index looked exactly once, so a takeover that landed in that window found nothing and gave up. The failure is worse than a missing profile: ownership has already been taken from the other device by then, so the audio ends up nowhere. Playback stops on the device that was working and never starts on this one. Poll for up to three seconds instead of looking once. --- linux-rust/src/media_controller.rs | 49 ++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 5bd68b1a2..dd7e3dfdf 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -617,22 +617,45 @@ impl MediaController { if mac.is_empty() { return None; } - let mac_clone = mac.to_string(); - tokio::task::spawn_blocking(move || { - for card in get_card_info_list_sync() { - if let Some(device_string) = card.proplist.get_str("device.string") - && device_string.contains(&mac_clone) - { - info!("Found audio device index for MAC {}: {}", mac_clone, card.index); - return Some(card.index); + // Taking the connection back from another device makes the earbuds + // renegotiate, and the card briefly disappears from the sound server + // while that happens. A single lookup can land in that window and give + // up, leaving the audio nowhere: ownership already taken from the other + // device, but no local profile to play through. So poll for a moment. + const ATTEMPTS: u32 = 12; + const INTERVAL: Duration = Duration::from_millis(250); + + for attempt in 1..=ATTEMPTS { + let mac_clone = mac.to_string(); + let found = tokio::task::spawn_blocking(move || { + get_card_info_list_sync().into_iter().find_map(|card| { + let device_string = card.proplist.get_str("device.string")?; + device_string.contains(&mac_clone).then_some(card.index) + }) + }) + .await + .unwrap_or(None); + + if let Some(index) = found { + if attempt > 1 { + debug!("Found audio device for {mac} after {attempt} attempts"); } + info!("Found audio device index for MAC {}: {}", mac, index); + return Some(index); } - error!("No matching Bluetooth card found for MAC address: {}", mac_clone); - None - }) - .await - .unwrap_or(None) + + if attempt < ATTEMPTS { + tokio::time::sleep(INTERVAL).await; + } + } + + error!( + "No matching Bluetooth card found for MAC address: {} after {:?}", + mac, + INTERVAL * ATTEMPTS + ); + None } pub async fn deactivate_a2dp_profile(&self) { From 226d42f85485df7b1440e4bd77f26f56242350ad Mon Sep 17 00:00:00 2001 From: Ramfi Aogusto Date: Tue, 11 Aug 2026 23:36:05 -0400 Subject: [PATCH 2/2] fix(linux): only take ownership on real transitions, not on startup state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places treated an unknown initial state as an observed one, and each made the takeover fire when nothing had happened. With a second device connected, that means pulling the audio away from a device the user was listening on. Ear detection: `old_all_out` is computed with `.all()` over the previous reading, which is vacuously true for an empty list. The first report after connecting therefore looks like the buds were just inserted even when they were never removed — LibrePods reinitialises on every reconnect, so a worn pair triggers a resume on each one. Record the first reading as a baseline instead. Playback: `is_playing` starts false as a placeholder. Anything already playing when the listener starts reads as playback that just began, so a reconnect while media is open takes the audio. Skip the first poll for the same reason. Self-inflicted pauses: losing ownership pauses local players and drops the audio profile, and players tend to resume once the profile returns. That resume was indistinguishable from the user pressing play, so it took ownership back from the device that had just claimed it — which pauses this side again, and the two devices trade the audio until neither is playing. `i_paused_the_media` already existed for this but was never set on that path. Together these stop the takeover from firing on reconnects and feedback loops, while a genuine play still takes the audio as before. --- linux-rust/src/media_controller.rs | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index dd7e3dfdf..56d9e55bf 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -124,6 +124,12 @@ impl MediaController { )>, ) { info!("Starting playback listener loop"); + // `is_playing` starts false, which is a placeholder rather than an + // observation. Whatever is already playing when this loop starts would + // otherwise read as playback that just began — and on a reconnect, with + // another device holding the audio, that escalates into taking it away + // from a device the user was happily listening on. + let mut baseline_taken = false; loop { tokio::time::sleep(Duration::from_millis(500)).await; @@ -137,6 +143,26 @@ impl MediaController { let local_mac = state.local_mac.clone(); drop(state); + if !baseline_taken { + baseline_taken = true; + debug!("Recorded initial playback state ({is_playing}); not a transition"); + continue; + } + + // Losing ownership pauses local players and drops the audio + // profile. Players tend to resume on their own once the profile + // comes back, and that resume is not the user asking for the audio + // — treating it as one starts a tug of war with the device that + // just took over, ending with neither side playing. + if is_playing && !was_playing { + let mut state = self.state.lock().await; + if state.i_paused_the_media { + state.i_paused_the_media = false; + debug!("Playback resumed after our own pause; not taking ownership"); + continue; + } + } + if !was_playing && is_playing { let aacp_state = aacp_manager.state.lock().await; if !aacp_state @@ -236,6 +262,19 @@ impl MediaController { } } + // No previous reading means this is the first report after connecting, + // not a change the wearer made. It matters because `old_all_out` is + // vacuously true for an empty list, so an already-worn pair looks like + // it was just put in: playback resumes, and with a second device + // connected the resume escalates into taking the audio away from it. + // Record the baseline and wait for a real transition instead. + if old_statuses.is_empty() { + debug!("First ear reading after connecting: recording baseline, not acting"); + let mut state = self.state.lock().await; + state.old_in_ear_data = new_in_ear_data; + return; + } + if new_has_at_least_one_in && old_all_out { debug!("Condition met: buds inserted, activating A2DP and checking play state"); self.activate_a2dp_profile().await; @@ -429,6 +468,10 @@ impl MediaController { pub async fn pause_all_media(&self) { debug!("Pausing all media (without tracking for resume)"); + // Remember that the pause came from us, so the playback listener does + // not mistake the players coming back for the user starting something. + self.state.lock().await.i_paused_the_media = true; + let paused_count = tokio::task::spawn_blocking(|| { let conn = match Connection::new_session() { Ok(c) => c,