diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index f06a319..869abf6 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -38,9 +38,7 @@ class BluetoothAudioManager: ObservableObject { private var observers: [NSObjectProtocol] = [] private var cancellables = Set() private let coordinator = DynamicIslandViewCoordinator.shared - private var pollingTimer: DispatchSourceTimer? - private let pollingQueue = DispatchQueue(label: "com.dynamicisland.bluetooth.polling", qos: .utility) - private let postConnectReReadDelays: [TimeInterval] = [0.5, 2.0] + private var pollingTimer: Timer? private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth" private let batteryReader = BluetoothLEBatteryReader() private var isLiveBatteryRefreshInFlight = false @@ -119,50 +117,26 @@ class BluetoothAudioManager: ObservableObject { print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter") } - /// 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. + /// Starts polling for device connection changes (fallback mechanism) private func startPollingForChanges() { print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...") - - 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() - } + + pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + 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() { - switch bluetoothIsPoweredOn() { - case .some(false): + // Check if Bluetooth is powered on + guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else { + // Bluetooth is off - clear connected devices if any 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 { @@ -195,9 +169,10 @@ class BluetoothAudioManager: ObservableObject { /// Checks for already connected Bluetooth audio devices on init private func checkInitialDevices() { print("🎧 [BluetoothAudioManager] Checking for initially connected devices...") - - guard bluetoothIsPoweredOn() == true else { - print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth not ready - skipping initial check") + + // Check if Bluetooth is powered on + guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else { + print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping initial check") return } @@ -247,8 +222,9 @@ class BluetoothAudioManager: ObservableObject { /// Checks for newly connected devices and displays HUD for new ones private func checkForNewlyConnectedDevices() { - guard bluetoothIsPoweredOn() == true else { - print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth not ready - skipping device check") + // Check if Bluetooth is powered on + guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else { + print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping device check") return } @@ -278,8 +254,7 @@ class BluetoothAudioManager: ObservableObject { isBluetoothAudioConnected = true refreshBatteryLevelsForConnectedDevices() - scheduleDeviceMetadataReReads(forAddress: audioDevice.address) - + // Show HUD for new connection if let refreshedDevice = connectedDevices.last { showDeviceConnectedHUD(refreshedDevice) @@ -289,54 +264,6 @@ 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() { @@ -1765,8 +1692,8 @@ class BluetoothAudioManager: ObservableObject { private func cleanup() { print("🎧 [BluetoothAudioManager] Cleaning up observers...") - - pollingTimer?.cancel() + + pollingTimer?.invalidate() pollingTimer = nil let dnc = DistributedNotificationCenter.default() @@ -2083,7 +2010,7 @@ extension BluetoothAudioDevice { } } -enum BluetoothAudioDeviceType: Equatable { +enum BluetoothAudioDeviceType { case airpods case airpodsGen3 case airpodsGen4 diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 59f6fd1..95da4fc 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -312,31 +312,11 @@ extension LockScreenManager { @MainActor final class LockSoundPlayer { static let shared = LockSoundPlayer() - // 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 let throttleInterval: TimeInterval = 0.25 private var players: [SoundType: AVAudioPlayer] = [:] private var lastPlaybackDates: [SoundType: Date] = [:] - private var lastPlayedType: SoundType? - private var lastAnyPlaybackDate: Date? - private var lockSoundsEnabledCancellable: AnyCancellable? - 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() - } - } - } + private init() {} func playLockChime() { play(.lock) @@ -353,31 +333,12 @@ final class LockSoundPlayer { player.currentTime = 0 player.play() - let now = Date() - lastPlaybackDates[type] = now - lastAnyPlaybackDate = now - lastPlayedType = type + lastPlaybackDates[type] = Date() } private func shouldPlay(_ type: SoundType) -> Bool { - 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 + guard let last = lastPlaybackDates[type] else { return true } + return Date().timeIntervalSince(last) >= throttleInterval } private func resolvePlayer(for type: SoundType) -> AVAudioPlayer? { @@ -401,7 +362,7 @@ final class LockSoundPlayer { } } - private enum SoundType: String, Equatable { + private enum SoundType: String { case lock case unlock diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 2aa8775..7abe47e 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -463,9 +463,7 @@ class StatsManager: ObservableObject { private var cancellables = Set() private let minUpdateInterval: TimeInterval = 1.0 private let maxUpdateInterval: TimeInterval = 60.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 notchCloseStopDelay: TimeInterval = 3.0 private let tabSwitchStopDelay: TimeInterval = 0.1 // MARK: - Initialization