Compare commits

..

No commits in common. "3e5f7651dd30bc1523de8d74c62cba2d296f25bf" and "6acb98aadbb7a459e8e792aff10ca93130318bb1" have entirely different histories.

3 changed files with 27 additions and 141 deletions

View file

@ -38,9 +38,7 @@ class BluetoothAudioManager: ObservableObject {
private var observers: [NSObjectProtocol] = [] private var observers: [NSObjectProtocol] = []
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private let coordinator = DynamicIslandViewCoordinator.shared private let coordinator = DynamicIslandViewCoordinator.shared
private var pollingTimer: DispatchSourceTimer? private var pollingTimer: Timer?
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 bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
private let batteryReader = BluetoothLEBatteryReader() private let batteryReader = BluetoothLEBatteryReader()
private var isLiveBatteryRefreshInFlight = false private var isLiveBatteryRefreshInFlight = false
@ -119,50 +117,26 @@ class BluetoothAudioManager: ObservableObject {
print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter") 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() { private func startPollingForChanges() {
print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...") print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...")
let timer = DispatchSource.makeTimerSource(queue: pollingQueue) pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
timer.schedule(deadline: .now() + 3.0, repeating: 3.0, leeway: .milliseconds(500)) self?.checkForDeviceChanges()
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 /// Checks for device connection/disconnection changes
private func checkForDeviceChanges() { private func checkForDeviceChanges() {
switch bluetoothIsPoweredOn() { // Check if Bluetooth is powered on
case .some(false): guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
// Bluetooth is off - clear connected devices if any
if !connectedDevices.isEmpty { if !connectedDevices.isEmpty {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth powered off - clearing connected devices") print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth powered off - clearing connected devices")
connectedDevices.removeAll() connectedDevices.removeAll()
isBluetoothAudioConnected = false isBluetoothAudioConnected = false
} }
return 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 { guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
@ -195,9 +169,10 @@ class BluetoothAudioManager: ObservableObject {
/// Checks for already connected Bluetooth audio devices on init /// Checks for already connected Bluetooth audio devices on init
private func checkInitialDevices() { private func checkInitialDevices() {
print("🎧 [BluetoothAudioManager] Checking for initially connected devices...") print("🎧 [BluetoothAudioManager] Checking for initially connected devices...")
guard bluetoothIsPoweredOn() == true else { // Check if Bluetooth is powered on
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth not ready - skipping initial check") guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping initial check")
return return
} }
@ -247,8 +222,9 @@ class BluetoothAudioManager: ObservableObject {
/// Checks for newly connected devices and displays HUD for new ones /// Checks for newly connected devices and displays HUD for new ones
private func checkForNewlyConnectedDevices() { private func checkForNewlyConnectedDevices() {
guard bluetoothIsPoweredOn() == true else { // Check if Bluetooth is powered on
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth not ready - skipping device check") guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping device check")
return return
} }
@ -278,8 +254,7 @@ class BluetoothAudioManager: ObservableObject {
isBluetoothAudioConnected = true isBluetoothAudioConnected = true
refreshBatteryLevelsForConnectedDevices() refreshBatteryLevelsForConnectedDevices()
scheduleDeviceMetadataReReads(forAddress: audioDevice.address)
// Show HUD for new connection // Show HUD for new connection
if let refreshedDevice = connectedDevices.last { if let refreshedDevice = connectedDevices.last {
showDeviceConnectedHUD(refreshedDevice) 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) /// Updates the list of connected devices (for disconnections)
private func updateConnectedDevices() { private func updateConnectedDevices() {
@ -1765,8 +1692,8 @@ class BluetoothAudioManager: ObservableObject {
private func cleanup() { private func cleanup() {
print("🎧 [BluetoothAudioManager] Cleaning up observers...") print("🎧 [BluetoothAudioManager] Cleaning up observers...")
pollingTimer?.cancel() pollingTimer?.invalidate()
pollingTimer = nil pollingTimer = nil
let dnc = DistributedNotificationCenter.default() let dnc = DistributedNotificationCenter.default()
@ -2083,7 +2010,7 @@ extension BluetoothAudioDevice {
} }
} }
enum BluetoothAudioDeviceType: Equatable { enum BluetoothAudioDeviceType {
case airpods case airpods
case airpodsGen3 case airpodsGen3
case airpodsGen4 case airpodsGen4

View file

@ -312,31 +312,11 @@ extension LockScreenManager {
@MainActor @MainActor
final class LockSoundPlayer { final class LockSoundPlayer {
static let shared = LockSoundPlayer() static let shared = LockSoundPlayer()
// Same-type throttle defends against repeat same-state notifications private let throttleInterval: TimeInterval = 0.25
// (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 players: [SoundType: AVAudioPlayer] = [:]
private var lastPlaybackDates: [SoundType: Date] = [:] 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() { func playLockChime() {
play(.lock) play(.lock)
@ -353,31 +333,12 @@ final class LockSoundPlayer {
player.currentTime = 0 player.currentTime = 0
player.play() player.play()
let now = Date() lastPlaybackDates[type] = Date()
lastPlaybackDates[type] = now
lastAnyPlaybackDate = now
lastPlayedType = type
} }
private func shouldPlay(_ type: SoundType) -> Bool { private func shouldPlay(_ type: SoundType) -> Bool {
let now = Date() guard let last = lastPlaybackDates[type] else { return true }
if let last = lastPlaybackDates[type], return Date().timeIntervalSince(last) >= throttleInterval
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? { 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 lock
case unlock case unlock

View file

@ -463,9 +463,7 @@ class StatsManager: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private let minUpdateInterval: TimeInterval = 1.0 private let minUpdateInterval: TimeInterval = 1.0
private let maxUpdateInterval: TimeInterval = 60.0 private let maxUpdateInterval: TimeInterval = 60.0
// Shorter than the previous 3.0 s: keeps stats "warm" enough for fast open→close→open private let notchCloseStopDelay: TimeInterval = 3.0
// 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 private let tabSwitchStopDelay: TimeInterval = 0.1
// MARK: - Initialization // MARK: - Initialization