From 494f2d57ccc81cf0ece53cfa5ad6263cbb0729aa Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 7 Oct 2025 01:53:19 +0530 Subject: [PATCH] Fix: Polling speed for volume and brightness --- .../managers/SystemChangesObserver.swift | 249 ++++++++++++++++-- 1 file changed, 226 insertions(+), 23 deletions(-) diff --git a/DynamicIsland/managers/SystemChangesObserver.swift b/DynamicIsland/managers/SystemChangesObserver.swift index 6db5e45..fb0e904 100644 --- a/DynamicIsland/managers/SystemChangesObserver.swift +++ b/DynamicIsland/managers/SystemChangesObserver.swift @@ -9,6 +9,7 @@ import Cocoa import Foundation import Defaults +import CoreAudio class SystemChangesObserver { private var oldVolume: Float = 0 @@ -17,8 +18,22 @@ class SystemChangesObserver { private weak var coordinator: DynamicIslandViewCoordinator? - // Backup timer for edge cases only - private var backupTimer: Timer? + // Adaptive polling timer + private var pollingTimer: Timer? + private var currentPollingInterval: TimeInterval = 2.0 + + // Polling intervals + private let rapidPollingInterval: TimeInterval = 0.05 // Burst mode for 5 seconds after key press + private let fastPollingInterval: TimeInterval = 0.3 // Fast mode when headphones connected + private let normalPollingInterval: TimeInterval = 2.0 // Normal mode when idle + + // Activity tracking + private var lastBrightnessChangeTime: Date = Date.distantPast + private var lastVolumeChangeTime: Date = Date.distantPast + private var lastVolumeCheckTime: Date = Date.distantPast + + // Headphone detection + private var hasHeadphonesConnected: Bool = false init(coordinator: DynamicIslandViewCoordinator) { self.coordinator = coordinator @@ -32,38 +47,170 @@ class SystemChangesObserver { } catch { NSLog("Failed to retrieve initial display brightness: \(error)") } + + // Detect headphones on init + updateHeadphoneStatus() + setupAudioRouteChangeListener() } func startObserving() { guard Defaults[.enableSystemHUD] else { return } - startMinimalBackupTimer() + // Start with appropriate polling rate + startPollingTimer(interval: determinePollingInterval()) } - private func startMinimalBackupTimer() { - // More frequent backup check (every 300ms) for better responsiveness - // This is a reasonable compromise between the original 200ms and the previous 5s - backupTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in - guard Defaults[.enableSystemHUD] else { return } - self?.performBackupCheck() + private func determinePollingInterval() -> TimeInterval { + let now = Date() + + // Burst mode: Rapid polling within 5 seconds of any key press + if now.timeIntervalSince(lastVolumeChangeTime) < 5.0 || + now.timeIntervalSince(lastBrightnessChangeTime) < 5.0 { + return rapidPollingInterval } + + // Fast mode: When headphones are connected + if hasHeadphonesConnected { + return fastPollingInterval + } + + // Normal mode: Idle state + return normalPollingInterval } - private func performBackupCheck() { - // Only check if we haven't had recent activity - checkVolumeChanges() - checkBrightnessChanges() + private func startPollingTimer(interval: TimeInterval) { + // Don't restart if same interval + if pollingTimer != nil && currentPollingInterval == interval { + return + } + + currentPollingInterval = interval + pollingTimer?.invalidate() + + pollingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + guard let self = self, Defaults[.enableSystemHUD] else { return } + + // Check if we need to adjust polling rate + let newInterval = self.determinePollingInterval() + if newInterval != self.currentPollingInterval { + self.startPollingTimer(interval: newInterval) + return + } + + // Poll volume + self.checkVolumeChanges() + + // Poll brightness only within 5 seconds of key press + if Date().timeIntervalSince(self.lastBrightnessChangeTime) < 5.0 { + self.checkBrightnessChanges() + } + } + + let mode = interval == rapidPollingInterval ? "BURST (50ms)" : + interval == fastPollingInterval ? "FAST (300ms)" : "NORMAL (2s)" + print("✅ Polling started: \(mode) mode") + } + + // MARK: - Headphone Detection + + private func setupAudioRouteChangeListener() { + // Listen for default audio device changes + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + AudioObjectAddPropertyListener( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + audioDeviceChangeListener, + Unmanaged.passUnretained(self).toOpaque() + ) + } + + @objc fileprivate func audioRouteChanged() { + updateHeadphoneStatus() + + // Adjust polling rate immediately + startPollingTimer(interval: determinePollingInterval()) + } + + private func updateHeadphoneStatus() { + // Check for headphones/external audio devices + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var deviceID: AudioDeviceID = 0 + var propertySize = UInt32(MemoryLayout.size) + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &propertySize, + &deviceID + ) + + guard status == noErr else { + hasHeadphonesConnected = false + return + } + + // Check if device is not built-in speakers + var transportType: UInt32 = 0 + var transportSize = UInt32(MemoryLayout.size) + var transportAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyTransportType, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let transportStatus = AudioObjectGetPropertyData( + deviceID, + &transportAddress, + 0, + nil, + &transportSize, + &transportType + ) + + // Consider headphones if: Bluetooth, USB, or any non-built-in device + let wasConnected = hasHeadphonesConnected + hasHeadphonesConnected = transportStatus == noErr && + transportType != kAudioDeviceTransportTypeBuiltIn + + if wasConnected != hasHeadphonesConnected { + print(hasHeadphonesConnected ? "🎧 Headphones connected - switching to FAST polling" : + "🔊 Headphones disconnected - switching to NORMAL polling") + } } // MARK: - Public Media Key Event Handlers (called from MediaKeyApplication) func handleVolumeKeyEvent() { + // Mark as user-initiated and trigger burst mode + lastVolumeChangeTime = Date() + + // Switch to burst mode immediately + startPollingTimer(interval: rapidPollingInterval) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.handleVolumeKeyPress() } } func handleBrightnessKeyEvent() { + // Mark as user-initiated and trigger burst mode + lastBrightnessChangeTime = Date() + + // Switch to burst mode immediately + startPollingTimer(interval: rapidPollingInterval) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.handleBrightnessKeyPress() } @@ -74,6 +221,9 @@ class SystemChangesObserver { private func handleVolumeKeyPress() { guard Defaults[.enableVolumeHUD] else { return } + // Mark as user-initiated (don't debounce key presses) + lastVolumeChangeTime = Date() + let newVolume = SystemVolumeManager.getOutputVolume() let newMuted = SystemVolumeManager.isMuted() @@ -99,7 +249,7 @@ class SystemChangesObserver { let newBrightness = try SystemDisplayManager.getDisplayBrightness() // Always show HUD for key press feedback, even if value doesn't change - print("☀️ Brightness key pressed - showing HUD: brightness=\(newBrightness)") + print("☀️ Brightness key pressed - showing HUD: brightness=\(newBrightness) [BURST MODE]") sendBrightnessNotification(value: newBrightness) // Update stored value @@ -108,10 +258,21 @@ class SystemChangesObserver { // Silently ignore brightness errors to reduce log spam } } - + + // MARK: - Polling Check Methods + private func checkVolumeChanges() { guard Defaults[.enableVolumeHUD] else { return } + // Throttle AppleScript checks based on current mode + let now = Date() + let minInterval: TimeInterval = currentPollingInterval == rapidPollingInterval ? 0.05 : 0.2 + + if now.timeIntervalSince(lastVolumeCheckTime) < minInterval { + return + } + lastVolumeCheckTime = now + let newVolume = SystemVolumeManager.getOutputVolume() let newMuted = SystemVolumeManager.isMuted() @@ -119,13 +280,21 @@ class SystemChangesObserver { let muteChanged = newMuted != oldMuted if volumeChanged || muteChanged { - print("🔊 Volume change detected: \(oldVolume) → \(newVolume), muted: \(oldMuted) → \(newMuted)") + // Don't show HUD if change was just triggered by key press (within 0.1s in burst mode, 0.3s otherwise) + let debounceWindow = currentPollingInterval == rapidPollingInterval ? 0.1 : 0.3 + let timeSinceKeyPress = now.timeIntervalSince(lastVolumeChangeTime) - if newMuted { - sendVolumeNotification(value: 0.0) - } else { - sendVolumeNotification(value: newVolume) + if timeSinceKeyPress > debounceWindow { + let mode = currentPollingInterval == rapidPollingInterval ? "[BURST]" : "[EXTERNAL]" + print("🔊 Volume change detected \(mode): \(oldVolume) → \(newVolume), muted: \(oldMuted) → \(newMuted)") + + if newMuted { + sendVolumeNotification(value: 0.0) + } else { + sendVolumeNotification(value: newVolume) + } } + oldVolume = newVolume oldMuted = newMuted } @@ -178,16 +347,50 @@ class SystemChangesObserver { } deinit { + // Remove audio device change listener + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + AudioObjectRemovePropertyListener( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + audioDeviceChangeListener, + Unmanaged.passUnretained(self).toOpaque() + ) + stopObserving() } func stopObserving() { - // Clean up backup timer - backupTimer?.invalidate() - backupTimer = nil + // Clean up polling timer + pollingTimer?.invalidate() + pollingTimer = nil print("🎹 Media key detection stopped") } } +// MARK: - CoreAudio Callback for Device Changes + +private func audioDeviceChangeListener( + _ inObjectID: AudioObjectID, + _ inNumberAddresses: UInt32, + _ inAddresses: UnsafePointer, + _ inClientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let inClientData = inClientData else { return noErr } + + let observer = Unmanaged.fromOpaque(inClientData).takeUnretainedValue() + + // Handle on main thread + DispatchQueue.main.async { + observer.audioRouteChanged() + } + + return noErr +} +