diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index 869abf6..f06a319 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -38,7 +38,9 @@ class BluetoothAudioManager: ObservableObject { private var observers: [NSObjectProtocol] = [] private var cancellables = Set() 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 diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 95da4fc..59f6fd1 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -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 diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 7abe47e..2aa8775 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -463,7 +463,9 @@ class StatsManager: ObservableObject { private var cancellables = Set() 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