Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 79 additions & 13 deletions linux-rust/src/media_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down