Fix: Polling speed for volume and brightness

This commit is contained in:
Hariharan Mudaliar 2025-10-07 01:53:19 +05:30
commit 494f2d57cc

View file

@ -9,6 +9,7 @@
import Cocoa import Cocoa
import Foundation import Foundation
import Defaults import Defaults
import CoreAudio
class SystemChangesObserver { class SystemChangesObserver {
private var oldVolume: Float = 0 private var oldVolume: Float = 0
@ -17,8 +18,22 @@ class SystemChangesObserver {
private weak var coordinator: DynamicIslandViewCoordinator? private weak var coordinator: DynamicIslandViewCoordinator?
// Backup timer for edge cases only // Adaptive polling timer
private var backupTimer: 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) { init(coordinator: DynamicIslandViewCoordinator) {
self.coordinator = coordinator self.coordinator = coordinator
@ -32,38 +47,170 @@ class SystemChangesObserver {
} catch { } catch {
NSLog("Failed to retrieve initial display brightness: \(error)") NSLog("Failed to retrieve initial display brightness: \(error)")
} }
// Detect headphones on init
updateHeadphoneStatus()
setupAudioRouteChangeListener()
} }
func startObserving() { func startObserving() {
guard Defaults[.enableSystemHUD] else { return } guard Defaults[.enableSystemHUD] else { return }
startMinimalBackupTimer() // Start with appropriate polling rate
startPollingTimer(interval: determinePollingInterval())
} }
private func startMinimalBackupTimer() { private func determinePollingInterval() -> TimeInterval {
// More frequent backup check (every 300ms) for better responsiveness let now = Date()
// This is a reasonable compromise between the original 200ms and the previous 5s
backupTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in // Burst mode: Rapid polling within 5 seconds of any key press
guard Defaults[.enableSystemHUD] else { return } if now.timeIntervalSince(lastVolumeChangeTime) < 5.0 ||
self?.performBackupCheck() 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 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()
} }
} }
private func performBackupCheck() { let mode = interval == rapidPollingInterval ? "BURST (50ms)" :
// Only check if we haven't had recent activity interval == fastPollingInterval ? "FAST (300ms)" : "NORMAL (2s)"
checkVolumeChanges() print("✅ Polling started: \(mode) mode")
checkBrightnessChanges() }
// 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<AudioDeviceID>.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<UInt32>.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) // MARK: - Public Media Key Event Handlers (called from MediaKeyApplication)
func handleVolumeKeyEvent() { 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) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
self.handleVolumeKeyPress() self.handleVolumeKeyPress()
} }
} }
func handleBrightnessKeyEvent() { 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) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
self.handleBrightnessKeyPress() self.handleBrightnessKeyPress()
} }
@ -74,6 +221,9 @@ class SystemChangesObserver {
private func handleVolumeKeyPress() { private func handleVolumeKeyPress() {
guard Defaults[.enableVolumeHUD] else { return } guard Defaults[.enableVolumeHUD] else { return }
// Mark as user-initiated (don't debounce key presses)
lastVolumeChangeTime = Date()
let newVolume = SystemVolumeManager.getOutputVolume() let newVolume = SystemVolumeManager.getOutputVolume()
let newMuted = SystemVolumeManager.isMuted() let newMuted = SystemVolumeManager.isMuted()
@ -99,7 +249,7 @@ class SystemChangesObserver {
let newBrightness = try SystemDisplayManager.getDisplayBrightness() let newBrightness = try SystemDisplayManager.getDisplayBrightness()
// Always show HUD for key press feedback, even if value doesn't change // 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) sendBrightnessNotification(value: newBrightness)
// Update stored value // Update stored value
@ -109,9 +259,20 @@ class SystemChangesObserver {
} }
} }
// MARK: - Polling Check Methods
private func checkVolumeChanges() { private func checkVolumeChanges() {
guard Defaults[.enableVolumeHUD] else { return } 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 newVolume = SystemVolumeManager.getOutputVolume()
let newMuted = SystemVolumeManager.isMuted() let newMuted = SystemVolumeManager.isMuted()
@ -119,13 +280,21 @@ class SystemChangesObserver {
let muteChanged = newMuted != oldMuted let muteChanged = newMuted != oldMuted
if volumeChanged || muteChanged { 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 timeSinceKeyPress > debounceWindow {
let mode = currentPollingInterval == rapidPollingInterval ? "[BURST]" : "[EXTERNAL]"
print("🔊 Volume change detected \(mode): \(oldVolume) → \(newVolume), muted: \(oldMuted) → \(newMuted)")
if newMuted { if newMuted {
sendVolumeNotification(value: 0.0) sendVolumeNotification(value: 0.0)
} else { } else {
sendVolumeNotification(value: newVolume) sendVolumeNotification(value: newVolume)
} }
}
oldVolume = newVolume oldVolume = newVolume
oldMuted = newMuted oldMuted = newMuted
} }
@ -178,16 +347,50 @@ class SystemChangesObserver {
} }
deinit { 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() stopObserving()
} }
func stopObserving() { func stopObserving() {
// Clean up backup timer // Clean up polling timer
backupTimer?.invalidate() pollingTimer?.invalidate()
backupTimer = nil pollingTimer = nil
print("🎹 Media key detection stopped") print("🎹 Media key detection stopped")
} }
} }
// MARK: - CoreAudio Callback for Device Changes
private func audioDeviceChangeListener(
_ inObjectID: AudioObjectID,
_ inNumberAddresses: UInt32,
_ inAddresses: UnsafePointer<AudioObjectPropertyAddress>,
_ inClientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let inClientData = inClientData else { return noErr }
let observer = Unmanaged<SystemChangesObserver>.fromOpaque(inClientData).takeUnretainedValue()
// Handle on main thread
DispatchQueue.main.async {
observer.audioRouteChanged()
}
return noErr
}