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 cancellables = Set<AnyCancellable>()
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

View file

@ -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

View file

@ -463,9 +463,7 @@ class StatsManager: ObservableObject {
private var cancellables = Set<AnyCancellable>()
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