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:
FBukovina 2026-05-21 21:33:41 +02:00
commit d139742660
3 changed files with 141 additions and 27 deletions

View file

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