Merge pull request #1 from FBukovina/fix/bluetooth-stability-and-lid-sound

fix: stabilize Bluetooth polling and dedupe lid-cycle lock chimes
This commit is contained in:
Filip Bukovina 2026-05-21 21:35:30 +02:00 • committed by GitHub
commit 3e5f7651dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 141 additions and 27 deletions

View file

@ -38,7 +38,9 @@ class BluetoothAudioManager: ObservableObject {
private var observers: [NSObjectProtocol] = []
private var cancellables = Set<AnyCancellable>()
private let coordinator = DynamicIslandViewCoordinator.shared
private var pollingTimer: Timer?
private var pollingTimer: DispatchSourceTimer?
private let pollingQueue = DispatchQueue(label: "com.dynamicisland.bluetooth.polling", qos: .utility)
private let postConnectReReadDelays: [TimeInterval] = [0.5, 2.0]
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
private let batteryReader = BluetoothLEBatteryReader()
private var isLiveBatteryRefreshInFlight = false
@ -117,26 +119,50 @@ class BluetoothAudioManager: ObservableObject {
print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter")
}
/// Starts polling for device connection changes (fallback mechanism)
/// Starts polling for device connection changes (fallback mechanism).
/// Uses DispatchSourceTimer so main-thread back-pressure (SwiftUI updates, animations)
/// can't stall the cadence and widen the window where a missed
/// IOBluetoothDeviceConnectedNotification goes unnoticed.
private func startPollingForChanges() {
print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...")
pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
let timer = DispatchSource.makeTimerSource(queue: pollingQueue)
timer.schedule(deadline: .now() + 3.0, repeating: 3.0, leeway: .milliseconds(500))
timer.setEventHandler { [weak self] in
DispatchQueue.main.async {
self?.checkForDeviceChanges()
}
}
timer.resume()
pollingTimer = timer
}
/// Reads the Bluetooth host-controller power state. Returns `nil` when the
/// controller object itself is unavailable — distinct from "powered off" so
/// callers can preserve in-memory device state across early-launch or transient
/// IOBluetooth registry blips instead of mistakenly clearing connectedDevices.
private func bluetoothIsPoweredOn() -> Bool? {
guard let controller = IOBluetoothHostController.default() else {
return nil
}
return controller.powerState == kBluetoothHCIPowerStateON
}
/// Checks for device connection/disconnection changes
private func checkForDeviceChanges() {
// Check if Bluetooth is powered on
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
// Bluetooth is off - clear connected devices if any
switch bluetoothIsPoweredOn() {
case .some(false):
if !connectedDevices.isEmpty {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth powered off - clearing connected devices")
connectedDevices.removeAll()
isBluetoothAudioConnected = false
}
return
case .none:
// Controller momentarily unavailable; preserve last-known state and retry next tick.
return
case .some(true):
break
}
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
@ -170,9 +196,8 @@ class BluetoothAudioManager: ObservableObject {
private func checkInitialDevices() {
print("🎧 [BluetoothAudioManager] Checking for initially connected devices...")
// Check if Bluetooth is powered on
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping initial check")
guard bluetoothIsPoweredOn() == true else {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth not ready - skipping initial check")
return
}
@ -222,9 +247,8 @@ class BluetoothAudioManager: ObservableObject {
/// Checks for newly connected devices and displays HUD for new ones
private func checkForNewlyConnectedDevices() {
// Check if Bluetooth is powered on
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping device check")
guard bluetoothIsPoweredOn() == true else {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth not ready - skipping device check")
return
}
@ -254,6 +278,7 @@ class BluetoothAudioManager: ObservableObject {
isBluetoothAudioConnected = true
refreshBatteryLevelsForConnectedDevices()
scheduleDeviceMetadataReReads(forAddress: audioDevice.address)
// Show HUD for new connection
if let refreshedDevice = connectedDevices.last {
@ -265,6 +290,54 @@ class BluetoothAudioManager: ObservableObject {
}
}
/// IOBluetoothDevice properties (name, vendor/product ID, registry battery)
/// are not always populated at the instant IOBluetoothDeviceConnectedNotification
/// fires. Re-read the device after a short settle period to catch the
/// late-population window without spamming the registry.
private func scheduleDeviceMetadataReReads(forAddress address: String) {
for delay in postConnectReReadDelays {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.refreshDeviceMetadata(forAddress: address)
}
}
}
private func refreshDeviceMetadata(forAddress address: String) {
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice],
let ioDevice = pairedDevices.first(where: { $0.addressString == address && $0.isConnected() })
else { return }
guard let index = connectedDevices.firstIndex(where: { $0.address == address }) else { return }
let existing = connectedDevices[index]
let updatedName = ioDevice.name ?? existing.name
let updatedType = detectDeviceType(from: ioDevice, name: updatedName)
let updatedBattery = getBatteryLevel(from: ioDevice) ?? existing.batteryLevel
let nameChanged = updatedName != existing.name
let typeChanged = updatedType != existing.deviceType
let batteryChanged = updatedBattery != existing.batteryLevel
guard nameChanged || typeChanged || batteryChanged else { return }
let refreshed = BluetoothAudioDevice(
id: existing.id,
name: updatedName,
address: existing.address,
batteryLevel: updatedBattery,
deviceType: updatedType
)
connectedDevices[index] = refreshed
if lastConnectedDevice?.address == address {
lastConnectedDevice = refreshed
}
print("🎧 [BluetoothAudioManager] 🔁 Refreshed metadata for \(updatedName) (name:\(nameChanged) type:\(typeChanged) battery:\(batteryChanged))")
if batteryChanged, let battery = updatedBattery {
updateActiveBluetoothHUDBattery(with: battery)
}
}
/// Updates the list of connected devices (for disconnections)
private func updateConnectedDevices() {
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
@ -1693,7 +1766,7 @@ class BluetoothAudioManager: ObservableObject {
private func cleanup() {
print("🎧 [BluetoothAudioManager] Cleaning up observers...")
pollingTimer?.invalidate()
pollingTimer?.cancel()
pollingTimer = nil
let dnc = DistributedNotificationCenter.default()
@ -2010,7 +2083,7 @@ extension BluetoothAudioDevice {
}
}
enum BluetoothAudioDeviceType {
enum BluetoothAudioDeviceType: Equatable {
case airpods
case airpodsGen3
case airpodsGen4

View file

@ -312,11 +312,31 @@ extension LockScreenManager {
@MainActor
final class LockSoundPlayer {
static let shared = LockSoundPlayer()
private let throttleInterval: TimeInterval = 0.25
// Same-type throttle defends against repeat same-state notifications
// (e.g. distributed + workspace observers both firing screenLocked).
private let throttleInterval: TimeInterval = 0.6
// Cross-type cooldown defends against the lid close→open scenario where
// macOS fires screenIsLocked and screenIsUnlocked within a few hundred ms
// of each other — without this, the user hears lock-chime-then-unlock-chime
// as a single doubled sound.
private let crossTypeCooldown: TimeInterval = 1.2
private var players: [SoundType: AVAudioPlayer] = [:]
private var lastPlaybackDates: [SoundType: Date] = [:]
private var lastPlayedType: SoundType?
private var lastAnyPlaybackDate: Date?
private var lockSoundsEnabledCancellable: AnyCancellable?
private init() {}
private init() {
// Reset throttle state when the user toggles enableLockSounds off then on,
// so the first chime after re-enable isn't suppressed by a stale timestamp.
lockSoundsEnabledCancellable = Defaults.publisher(.enableLockSounds)
.sink { [weak self] change in
guard change.newValue else { return }
Task { @MainActor in
self?.resetThrottleState()
}
}
}
func playLockChime() {
play(.lock)
@ -333,12 +353,31 @@ final class LockSoundPlayer {
player.currentTime = 0
player.play()
lastPlaybackDates[type] = Date()
let now = Date()
lastPlaybackDates[type] = now
lastAnyPlaybackDate = now
lastPlayedType = type
}
private func shouldPlay(_ type: SoundType) -> Bool {
guard let last = lastPlaybackDates[type] else { return true }
return Date().timeIntervalSince(last) >= throttleInterval
let now = Date()
if let last = lastPlaybackDates[type],
now.timeIntervalSince(last) < throttleInterval {
return false
}
if let lastAny = lastAnyPlaybackDate,
let lastType = lastPlayedType,
lastType != type,
now.timeIntervalSince(lastAny) < crossTypeCooldown {
return false
}
return true
}
private func resetThrottleState() {
lastPlaybackDates.removeAll()
lastAnyPlaybackDate = nil
lastPlayedType = nil
}
private func resolvePlayer(for type: SoundType) -> AVAudioPlayer? {
@ -362,7 +401,7 @@ final class LockSoundPlayer {
}
}
private enum SoundType: String {
private enum SoundType: String, Equatable {
case lock
case unlock

View file

@ -463,7 +463,9 @@ class StatsManager: ObservableObject {
private var cancellables = Set<AnyCancellable>()
private let minUpdateInterval: TimeInterval = 1.0
private let maxUpdateInterval: TimeInterval = 60.0
private let notchCloseStopDelay: TimeInterval = 3.0
// Shorter than the previous 3.0 s: keeps stats "warm" enough for fast open→close→open
// cycles while freeing the main-thread tick budget sooner after a deliberate close.
private let notchCloseStopDelay: TimeInterval = 1.0
private let tabSwitchStopDelay: TimeInterval = 0.1
// MARK: - Initialization