mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
fix: stabilize Bluetooth polling and dedupe lid-cycle lock chimes
BluetoothAudioManager - Replace Timer.scheduledTimer polling with DispatchSourceTimer on a utility queue so main-thread back-pressure no longer stalls the 3s connect-detection cadence. - Distinguish "host controller unavailable" from "powered off" via a new bluetoothIsPoweredOn() helper, so transient IOBluetooth registry blips at launch no longer clear connectedDevices. - Re-read device metadata (name, type, battery) at +500ms and +2s after IOBluetoothDeviceConnectedNotification to catch IORegistry's late-population window; the existing HUD wait-task then updates the shown battery in place. - Declare BluetoothAudioDeviceType: Equatable to support the change-detection diff in the re-read pass. LockSoundPlayer - Add a 1.2s cross-type cooldown so a rapid lid close->open no longer plays lock-then-unlock chimes as a doubled sound (the per-type throttle never covered this case). - Bump per-type throttle 0.25s -> 0.6s. - Reset throttle state when enableLockSounds is toggled back on, so the first chime after re-enable is never suppressed by a stale timestamp. StatsManager - Shorten notchCloseStopDelay 3.0s -> 1.0s so the monitoring timer is invalidated sooner after the user closes the notch, freeing the main-thread tick budget for animation work. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6acb98aadb
commit
d139742660
3 changed files with 141 additions and 27 deletions
|
|
@ -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
|
||||
self?.checkForDeviceChanges()
|
||||
|
||||
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 {
|
||||
|
|
@ -169,10 +195,9 @@ class BluetoothAudioManager: ObservableObject {
|
|||
/// Checks for already connected Bluetooth audio devices on init
|
||||
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,7 +278,8 @@ class BluetoothAudioManager: ObservableObject {
|
|||
isBluetoothAudioConnected = true
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
|
||||
scheduleDeviceMetadataReReads(forAddress: audioDevice.address)
|
||||
|
||||
// Show HUD for new connection
|
||||
if let refreshedDevice = connectedDevices.last {
|
||||
showDeviceConnectedHUD(refreshedDevice)
|
||||
|
|
@ -264,6 +289,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() {
|
||||
|
|
@ -1692,8 +1765,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue