diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 5bd68b1a2..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, @@ -617,22 +660,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) {