mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working volume
This commit is contained in:
parent
86f57c88b7
commit
b4f9e51c1f
6 changed files with 638 additions and 574 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -88,6 +88,7 @@ ONBOARDING_ENHANCEMENT.md
|
||||||
SHEET_PRESENTATION_FIX.md
|
SHEET_PRESENTATION_FIX.md
|
||||||
lock_mode_reference.md
|
lock_mode_reference.md
|
||||||
liquid_glass.md
|
liquid_glass.md
|
||||||
|
huds.md
|
||||||
|
|
||||||
MoveToSky.xcodeproj
|
MoveToSky.xcodeproj
|
||||||
/MoveToSky/
|
/MoveToSky/
|
||||||
|
|
@ -93,10 +93,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var windowsHiddenForLock = false
|
private var windowsHiddenForLock = false
|
||||||
|
|
||||||
// Media key monitoring
|
|
||||||
private var globalEventMonitor: Any?
|
|
||||||
private var localEventMonitor: Any?
|
|
||||||
|
|
||||||
// Debouncing mechanism for window size updates
|
// Debouncing mechanism for window size updates
|
||||||
private var windowSizeUpdateWorkItem: DispatchWorkItem?
|
private var windowSizeUpdateWorkItem: DispatchWorkItem?
|
||||||
// let calendarManager = CalendarManager.shared
|
// let calendarManager = CalendarManager.shared
|
||||||
|
|
@ -127,84 +123,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Media Key Monitoring
|
|
||||||
|
|
||||||
private func setupMediaKeyMonitoring() {
|
|
||||||
// Monitor both global and local events to catch media keys
|
|
||||||
globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .systemDefined) { [weak self] event in
|
|
||||||
self?.handleSystemEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .systemDefined) { [weak self] event in
|
|
||||||
self?.handleSystemEvent(event)
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
print("🎹 Media key monitoring started using NSEvent")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSystemEvent(_ event: NSEvent) {
|
|
||||||
guard event.subtype.rawValue == 8 else { return }
|
|
||||||
|
|
||||||
let data1 = event.data1
|
|
||||||
let keyCode = Int32((data1 & 0xFFFF0000) >> 16)
|
|
||||||
let keyFlags = Int(data1 & 0x0000FFFF)
|
|
||||||
let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA // KeyDown
|
|
||||||
let keyRepeat = (keyFlags & 0x1) != 0
|
|
||||||
|
|
||||||
guard keyState && !keyRepeat else { return } // Only handle key down, ignore repeats
|
|
||||||
|
|
||||||
switch keyCode {
|
|
||||||
case NX_KEYTYPE_SOUND_UP, NX_KEYTYPE_SOUND_DOWN, NX_KEYTYPE_MUTE:
|
|
||||||
print("🔊 Volume key detected: \(keyCode)")
|
|
||||||
SystemHUDManager.shared.handleVolumeKeyEvent()
|
|
||||||
case NX_KEYTYPE_BRIGHTNESS_UP, NX_KEYTYPE_BRIGHTNESS_DOWN:
|
|
||||||
print("☀️ Brightness key detected: \(keyCode)")
|
|
||||||
SystemHUDManager.shared.handleBrightnessKeyEvent()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopMediaKeyMonitoring() {
|
|
||||||
if let globalMonitor = globalEventMonitor {
|
|
||||||
NSEvent.removeMonitor(globalMonitor)
|
|
||||||
globalEventMonitor = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let localMonitor = localEventMonitor {
|
|
||||||
NSEvent.removeMonitor(localMonitor)
|
|
||||||
localEventMonitor = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
print("🎹 Media key monitoring stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MediaKeyApplicationDelegate (for future use)
|
|
||||||
|
|
||||||
func didDetectVolumeKey() {
|
|
||||||
SystemHUDManager.shared.handleVolumeKeyEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
func didDetectBrightnessKey() {
|
|
||||||
SystemHUDManager.shared.handleBrightnessKeyEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
// Cancel any pending window size updates
|
// Cancel any pending window size updates
|
||||||
windowSizeUpdateWorkItem?.cancel()
|
windowSizeUpdateWorkItem?.cancel()
|
||||||
|
|
||||||
// Stop media key monitoring
|
|
||||||
stopMediaKeyMonitoring()
|
|
||||||
|
|
||||||
print("🚪 Application terminating - ensuring system HUD is restored...")
|
|
||||||
|
|
||||||
// Re-enable system HUD on app termination
|
|
||||||
SystemOSDManager.enableSystemHUD()
|
|
||||||
|
|
||||||
// Give it time to complete
|
|
||||||
usleep(500000) // 500ms
|
|
||||||
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,9 +375,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// Setup Privacy Indicator Manager (camera and microphone monitoring)
|
// Setup Privacy Indicator Manager (camera and microphone monitoring)
|
||||||
PrivacyIndicatorManager.shared.startMonitoring()
|
PrivacyIndicatorManager.shared.startMonitoring()
|
||||||
|
|
||||||
// Setup media key monitoring
|
|
||||||
setupMediaKeyMonitoring()
|
|
||||||
|
|
||||||
// Observe tab changes - use debounced updates
|
// Observe tab changes - use debounced updates
|
||||||
coordinator.$currentView.sink { [weak self] newView in
|
coordinator.$currentView.sink { [weak self] newView in
|
||||||
self?.debouncedUpdateWindowSize()
|
self?.debouncedUpdateWindowSize()
|
||||||
|
|
|
||||||
204
DynamicIsland/managers/MediaKeyInterceptor.swift
Normal file
204
DynamicIsland/managers/MediaKeyInterceptor.swift
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
#if canImport(ApplicationServices)
|
||||||
|
import ApplicationServices
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private let NX_SYSDEFINED_EVENT_TYPE: UInt32 = 14
|
||||||
|
private let NX_KEYTYPE_SOUND_UP: Int32 = 0
|
||||||
|
private let NX_KEYTYPE_SOUND_DOWN: Int32 = 1
|
||||||
|
private let NX_KEYTYPE_BRIGHTNESS_UP: Int32 = 2
|
||||||
|
private let NX_KEYTYPE_BRIGHTNESS_DOWN: Int32 = 3
|
||||||
|
private let NX_KEYTYPE_MUTE: Int32 = 7
|
||||||
|
|
||||||
|
enum MediaKeyDirection {
|
||||||
|
case up
|
||||||
|
case down
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MediaKeyConfiguration {
|
||||||
|
var interceptVolume: Bool
|
||||||
|
var interceptBrightness: Bool
|
||||||
|
|
||||||
|
static let disabled = MediaKeyConfiguration(interceptVolume: false, interceptBrightness: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol MediaKeyInterceptorDelegate: AnyObject {
|
||||||
|
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveVolumeCommand direction: MediaKeyDirection, isRepeat: Bool)
|
||||||
|
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveBrightnessCommand direction: MediaKeyDirection, isRepeat: Bool)
|
||||||
|
func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class MediaKeyInterceptor {
|
||||||
|
static let shared = MediaKeyInterceptor()
|
||||||
|
|
||||||
|
weak var delegate: MediaKeyInterceptorDelegate?
|
||||||
|
var configuration: MediaKeyConfiguration = .disabled {
|
||||||
|
didSet {
|
||||||
|
updateTapState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var eventTap: CFMachPort?
|
||||||
|
private var runLoopSource: CFRunLoopSource?
|
||||||
|
private var isTapEnabled = false
|
||||||
|
#if canImport(ApplicationServices)
|
||||||
|
private var didRequestAccessibilityPrompt = false
|
||||||
|
#endif
|
||||||
|
private let systemDefinedEventType = CGEventType(rawValue: NX_SYSDEFINED_EVENT_TYPE)
|
||||||
|
private let eventTapLocations: [CGEventTapLocation] = [.cghidEventTap, .cgSessionEventTap]
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func start() -> Bool {
|
||||||
|
guard eventTap == nil else {
|
||||||
|
updateTapState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(ApplicationServices)
|
||||||
|
requestAccessibilityPermissionIfNeeded()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
guard let systemDefinedType = systemDefinedEventType else {
|
||||||
|
NSLog("❌ Unable to resolve system-defined event type")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let mask = CGEventMask(1) << systemDefinedType.rawValue
|
||||||
|
let callback: CGEventTapCallBack = { _, type, cgEvent, userInfo in
|
||||||
|
guard let userInfo else { return Unmanaged.passUnretained(cgEvent) }
|
||||||
|
let interceptor = Unmanaged<MediaKeyInterceptor>.fromOpaque(userInfo).takeUnretainedValue()
|
||||||
|
return interceptor.handleEvent(cgEvent: cgEvent, type: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdTap: CFMachPort?
|
||||||
|
for location in eventTapLocations {
|
||||||
|
if let tap = CGEvent.tapCreate(
|
||||||
|
tap: location,
|
||||||
|
place: .headInsertEventTap,
|
||||||
|
options: .defaultTap,
|
||||||
|
eventsOfInterest: mask,
|
||||||
|
callback: callback,
|
||||||
|
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
) {
|
||||||
|
createdTap = tap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let tap = createdTap else {
|
||||||
|
#if canImport(ApplicationServices)
|
||||||
|
if !AXIsProcessTrusted() {
|
||||||
|
NSLog("⚠️ Accessibility permission missing; grant access in System Settings › Privacy & Security › Accessibility")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
NSLog("❌ Failed to create media key event tap")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
eventTap = tap
|
||||||
|
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||||
|
if let source = runLoopSource {
|
||||||
|
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
|
||||||
|
}
|
||||||
|
CGEvent.tapEnable(tap: tap, enable: true)
|
||||||
|
isTapEnabled = true
|
||||||
|
NSLog("✅ Media key event tap installed (HID)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
if let tap = eventTap {
|
||||||
|
CGEvent.tapEnable(tap: tap, enable: false)
|
||||||
|
CFMachPortInvalidate(tap)
|
||||||
|
}
|
||||||
|
if let source = runLoopSource {
|
||||||
|
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
|
||||||
|
}
|
||||||
|
eventTap = nil
|
||||||
|
runLoopSource = nil
|
||||||
|
isTapEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTapState() {
|
||||||
|
guard let tap = eventTap else { return }
|
||||||
|
let shouldEnable = configuration.interceptVolume || configuration.interceptBrightness
|
||||||
|
if shouldEnable != isTapEnabled {
|
||||||
|
CGEvent.tapEnable(tap: tap, enable: shouldEnable)
|
||||||
|
isTapEnabled = shouldEnable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEvent(cgEvent: CGEvent, type: CGEventType) -> Unmanaged<CGEvent>? {
|
||||||
|
guard let systemDefinedType = systemDefinedEventType,
|
||||||
|
type == systemDefinedType,
|
||||||
|
let nsEvent = NSEvent(cgEvent: cgEvent),
|
||||||
|
nsEvent.subtype.rawValue == 8 else {
|
||||||
|
return Unmanaged.passUnretained(cgEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data1 = nsEvent.data1
|
||||||
|
let keyCode = (data1 & 0xFFFF0000) >> 16
|
||||||
|
let keyFlags = data1 & 0x0000FFFF
|
||||||
|
let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA // 0xA = keyDown, 0xB = keyUp
|
||||||
|
let isRepeat = (keyFlags & 0x0001) == 1
|
||||||
|
|
||||||
|
guard keyState else {
|
||||||
|
// Swallow key-up events only when intercepting, otherwise let them pass through
|
||||||
|
if shouldHandle(keyCode: Int32(keyCode)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Unmanaged.passUnretained(cgEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch Int32(keyCode) {
|
||||||
|
case NX_KEYTYPE_SOUND_UP:
|
||||||
|
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
|
||||||
|
delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .up, isRepeat: isRepeat)
|
||||||
|
return nil
|
||||||
|
case NX_KEYTYPE_SOUND_DOWN:
|
||||||
|
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
|
||||||
|
delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .down, isRepeat: isRepeat)
|
||||||
|
return nil
|
||||||
|
case NX_KEYTYPE_MUTE:
|
||||||
|
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
|
||||||
|
delegate?.mediaKeyInterceptorDidToggleMute(self)
|
||||||
|
return nil
|
||||||
|
case NX_KEYTYPE_BRIGHTNESS_UP:
|
||||||
|
guard configuration.interceptBrightness else { return Unmanaged.passUnretained(cgEvent) }
|
||||||
|
delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .up, isRepeat: isRepeat)
|
||||||
|
return nil
|
||||||
|
case NX_KEYTYPE_BRIGHTNESS_DOWN:
|
||||||
|
guard configuration.interceptBrightness else { return Unmanaged.passUnretained(cgEvent) }
|
||||||
|
delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .down, isRepeat: isRepeat)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return Unmanaged.passUnretained(cgEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldHandle(keyCode: Int32) -> Bool {
|
||||||
|
switch keyCode {
|
||||||
|
case NX_KEYTYPE_SOUND_UP, NX_KEYTYPE_SOUND_DOWN, NX_KEYTYPE_MUTE:
|
||||||
|
return configuration.interceptVolume
|
||||||
|
case NX_KEYTYPE_BRIGHTNESS_UP, NX_KEYTYPE_BRIGHTNESS_DOWN:
|
||||||
|
return configuration.interceptBrightness
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(ApplicationServices)
|
||||||
|
extension MediaKeyInterceptor {
|
||||||
|
private func requestAccessibilityPermissionIfNeeded() {
|
||||||
|
guard !AXIsProcessTrusted(), !didRequestAccessibilityPrompt else { return }
|
||||||
|
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||||
|
let options: CFDictionary = [promptKey: true] as CFDictionary
|
||||||
|
_ = AXIsProcessTrustedWithOptions(options)
|
||||||
|
didRequestAccessibilityPrompt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -1,346 +1,96 @@
|
||||||
//
|
|
||||||
// SystemChangesObserver.swift
|
|
||||||
// DynamicIsland
|
|
||||||
//
|
|
||||||
// Adapted from TheBoringWorker-HUD ChangesObserver.swift
|
|
||||||
// Created by GitHub Copilot on 06/09/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Cocoa
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
import Defaults
|
import Defaults
|
||||||
import CoreAudio
|
|
||||||
|
|
||||||
class SystemChangesObserver {
|
|
||||||
private var oldVolume: Float = 0
|
|
||||||
private var oldMuted: Bool = false
|
|
||||||
private var oldBrightness: Float = 0
|
|
||||||
|
|
||||||
|
final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
||||||
private weak var coordinator: DynamicIslandViewCoordinator?
|
private weak var coordinator: DynamicIslandViewCoordinator?
|
||||||
|
private let volumeController = SystemVolumeController.shared
|
||||||
|
private let brightnessController = SystemBrightnessController.shared
|
||||||
|
private let mediaKeyInterceptor = MediaKeyInterceptor.shared
|
||||||
|
|
||||||
// Adaptive polling timer
|
private let volumeStep: Float = 1.0 / 16.0
|
||||||
private var pollingTimer: Timer?
|
private let brightnessStep: Float = 1.0 / 16.0
|
||||||
private var currentPollingInterval: TimeInterval = 2.0
|
|
||||||
|
|
||||||
// Polling intervals
|
private var volumeEnabled = false
|
||||||
private let rapidPollingInterval: TimeInterval = 0.05 // Burst mode for active changes
|
private var brightnessEnabled = false
|
||||||
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
|
|
||||||
private var lastExternalChangeTime: Date = Date.distantPast // Track external volume changes
|
|
||||||
|
|
||||||
// Headphone detection (for info only, doesn't affect polling)
|
|
||||||
private var hasHeadphonesConnected: Bool = false
|
|
||||||
|
|
||||||
init(coordinator: DynamicIslandViewCoordinator) {
|
init(coordinator: DynamicIslandViewCoordinator) {
|
||||||
self.coordinator = coordinator
|
self.coordinator = coordinator
|
||||||
|
|
||||||
// Initialize baseline values only once
|
|
||||||
oldVolume = SystemVolumeManager.getOutputVolume()
|
|
||||||
oldMuted = SystemVolumeManager.isMuted()
|
|
||||||
|
|
||||||
do {
|
|
||||||
oldBrightness = try SystemDisplayManager.getDisplayBrightness()
|
|
||||||
} catch {
|
|
||||||
NSLog("Failed to retrieve initial display brightness: \(error)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect headphones on init
|
func startObserving(volumeEnabled: Bool, brightnessEnabled: Bool) {
|
||||||
updateHeadphoneStatus()
|
self.volumeEnabled = volumeEnabled
|
||||||
setupAudioRouteChangeListener()
|
self.brightnessEnabled = brightnessEnabled
|
||||||
|
|
||||||
|
volumeController.onVolumeChange = { [weak self] volume, muted in
|
||||||
|
guard let self, self.volumeEnabled else { return }
|
||||||
|
let value = muted ? 0 : volume
|
||||||
|
self.sendVolumeNotification(value: value)
|
||||||
}
|
}
|
||||||
|
volumeController.onRouteChange = { [weak self] in
|
||||||
func startObserving() {
|
guard let self, self.volumeEnabled else { return }
|
||||||
guard Defaults[.enableSystemHUD] else { return }
|
self.sendVolumeNotification(value: self.volumeController.isMuted ? 0 : self.volumeController.currentVolume)
|
||||||
|
|
||||||
// Start with appropriate polling rate
|
|
||||||
startPollingTimer(interval: determinePollingInterval())
|
|
||||||
}
|
}
|
||||||
|
volumeController.start()
|
||||||
|
|
||||||
private func determinePollingInterval() -> TimeInterval {
|
brightnessController.onBrightnessChange = { [weak self] brightness in
|
||||||
let now = Date()
|
guard let self, self.brightnessEnabled else { return }
|
||||||
|
self.sendBrightnessNotification(value: brightness)
|
||||||
// Burst mode: Rapid polling within 5 seconds of any activity
|
|
||||||
// Activity = key press OR external change detected
|
|
||||||
let timeSinceKeyPress = min(
|
|
||||||
now.timeIntervalSince(lastVolumeChangeTime),
|
|
||||||
now.timeIntervalSince(lastBrightnessChangeTime)
|
|
||||||
)
|
|
||||||
let timeSinceExternalChange = now.timeIntervalSince(lastExternalChangeTime)
|
|
||||||
|
|
||||||
// Use burst mode if there's been recent activity (within 5 seconds)
|
|
||||||
if timeSinceKeyPress < 5.0 || timeSinceExternalChange < 5.0 {
|
|
||||||
return rapidPollingInterval
|
|
||||||
}
|
}
|
||||||
|
brightnessController.start()
|
||||||
|
|
||||||
// Normal mode: Idle state (regardless of headphones)
|
mediaKeyInterceptor.delegate = self
|
||||||
return normalPollingInterval
|
let tapStarted = mediaKeyInterceptor.start()
|
||||||
|
if !tapStarted {
|
||||||
|
NSLog("⚠️ Media key interception unavailable; system HUD will remain visible")
|
||||||
}
|
}
|
||||||
|
mediaKeyInterceptor.configuration = MediaKeyConfiguration(
|
||||||
private func startPollingTimer(interval: TimeInterval) {
|
interceptVolume: volumeEnabled,
|
||||||
// Don't restart if same interval
|
interceptBrightness: brightnessEnabled
|
||||||
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)" : "NORMAL (2s)"
|
|
||||||
print("✅ Polling started: \(mode) mode\(hasHeadphonesConnected ? " [🎧 Headphones connected]" : "")")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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() {
|
func update(volumeEnabled: Bool, brightnessEnabled: Bool) {
|
||||||
updateHeadphoneStatus()
|
self.volumeEnabled = volumeEnabled
|
||||||
|
self.brightnessEnabled = brightnessEnabled
|
||||||
// Adjust polling rate immediately
|
mediaKeyInterceptor.configuration = MediaKeyConfiguration(
|
||||||
startPollingTimer(interval: determinePollingInterval())
|
interceptVolume: volumeEnabled,
|
||||||
}
|
interceptBrightness: brightnessEnabled
|
||||||
|
|
||||||
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
|
func stopObserving() {
|
||||||
var transportType: UInt32 = 0
|
mediaKeyInterceptor.stop()
|
||||||
var transportSize = UInt32(MemoryLayout<UInt32>.size)
|
mediaKeyInterceptor.delegate = nil
|
||||||
var transportAddress = AudioObjectPropertyAddress(
|
|
||||||
mSelector: kAudioDevicePropertyTransportType,
|
|
||||||
mScope: kAudioObjectPropertyScopeGlobal,
|
|
||||||
mElement: kAudioObjectPropertyElementMain
|
|
||||||
)
|
|
||||||
|
|
||||||
let transportStatus = AudioObjectGetPropertyData(
|
volumeController.stop()
|
||||||
deviceID,
|
volumeController.onVolumeChange = nil
|
||||||
&transportAddress,
|
volumeController.onRouteChange = nil
|
||||||
0,
|
|
||||||
nil,
|
|
||||||
&transportSize,
|
|
||||||
&transportType
|
|
||||||
)
|
|
||||||
|
|
||||||
// Consider headphones if: Bluetooth, USB, or any non-built-in device
|
brightnessController.stop()
|
||||||
let wasConnected = hasHeadphonesConnected
|
brightnessController.onBrightnessChange = nil
|
||||||
hasHeadphonesConnected = transportStatus == noErr &&
|
|
||||||
transportType != kAudioDeviceTransportTypeBuiltIn
|
|
||||||
|
|
||||||
if wasConnected != hasHeadphonesConnected {
|
|
||||||
print(hasHeadphonesConnected ? "🎧 Headphones connected (will use burst mode on change detection)" :
|
|
||||||
"🔊 Headphones disconnected")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Media Key Event Handlers (called from MediaKeyApplication)
|
// MARK: - MediaKeyInterceptorDelegate
|
||||||
|
|
||||||
func handleVolumeKeyEvent() {
|
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveVolumeCommand direction: MediaKeyDirection, isRepeat: Bool) {
|
||||||
// Mark as user-initiated and trigger burst mode
|
guard volumeEnabled else { return }
|
||||||
lastVolumeChangeTime = Date()
|
let delta = (direction == .up ? volumeStep : -volumeStep)
|
||||||
|
volumeController.adjust(by: delta)
|
||||||
// Switch to burst mode immediately
|
|
||||||
startPollingTimer(interval: rapidPollingInterval)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
||||||
self.handleVolumeKeyPress()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBrightnessKeyEvent() {
|
func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor) {
|
||||||
// Mark as user-initiated and trigger burst mode
|
guard volumeEnabled else { return }
|
||||||
lastBrightnessChangeTime = Date()
|
volumeController.toggleMute()
|
||||||
|
|
||||||
// Switch to burst mode immediately
|
|
||||||
startPollingTimer(interval: rapidPollingInterval)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
||||||
self.handleBrightnessKeyPress()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Key Press Event Handlers
|
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveBrightnessCommand direction: MediaKeyDirection, isRepeat: Bool) {
|
||||||
|
guard brightnessEnabled else { return }
|
||||||
private func handleVolumeKeyPress() {
|
let delta = (direction == .up ? brightnessStep : -brightnessStep)
|
||||||
guard Defaults[.enableVolumeHUD] else { return }
|
brightnessController.adjust(by: delta)
|
||||||
|
|
||||||
// Mark as user-initiated (don't debounce key presses)
|
|
||||||
lastVolumeChangeTime = Date()
|
|
||||||
|
|
||||||
let newVolume = SystemVolumeManager.getOutputVolume()
|
|
||||||
let newMuted = SystemVolumeManager.isMuted()
|
|
||||||
|
|
||||||
// Always show HUD for key press feedback, even if value doesn't change
|
|
||||||
print("🔊 Volume key pressed - showing HUD: volume=\(newVolume), muted=\(newMuted)")
|
|
||||||
|
|
||||||
if newMuted {
|
|
||||||
sendVolumeNotification(value: 0.0)
|
|
||||||
} else {
|
|
||||||
sendVolumeNotification(value: newVolume)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update stored values
|
// MARK: - HUD Dispatch
|
||||||
oldVolume = newVolume
|
|
||||||
oldMuted = newMuted
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleBrightnessKeyPress() {
|
|
||||||
guard Defaults[.enableBrightnessHUD] else { return }
|
|
||||||
guard NSScreen.screens.count > 0 else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
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) [BURST MODE]")
|
|
||||||
sendBrightnessNotification(value: newBrightness)
|
|
||||||
|
|
||||||
// Update stored value
|
|
||||||
oldBrightness = newBrightness
|
|
||||||
} catch {
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
let volumeChanged = !isAlmost(firstNumber: oldVolume, secondNumber: newVolume)
|
|
||||||
let muteChanged = newMuted != oldMuted
|
|
||||||
|
|
||||||
if volumeChanged || muteChanged {
|
|
||||||
// Check if this is an external change (not from key press)
|
|
||||||
let debounceWindow = currentPollingInterval == rapidPollingInterval ? 0.1 : 0.5
|
|
||||||
let timeSinceKeyPress = now.timeIntervalSince(lastVolumeChangeTime)
|
|
||||||
|
|
||||||
// External change detected!
|
|
||||||
if timeSinceKeyPress > debounceWindow {
|
|
||||||
// Mark it and trigger burst mode
|
|
||||||
lastExternalChangeTime = now
|
|
||||||
|
|
||||||
let mode = currentPollingInterval == rapidPollingInterval ? "[BURST]" : "[EXTERNAL→BURST]"
|
|
||||||
print("🔊 Volume change detected \(mode): \(oldVolume) → \(newVolume), muted: \(oldMuted) → \(newMuted)")
|
|
||||||
|
|
||||||
// Show HUD immediately
|
|
||||||
if newMuted {
|
|
||||||
sendVolumeNotification(value: 0.0)
|
|
||||||
} else {
|
|
||||||
sendVolumeNotification(value: newVolume)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch to burst mode immediately if we're in normal mode
|
|
||||||
if currentPollingInterval == normalPollingInterval {
|
|
||||||
print("⚡ Switching from NORMAL to BURST mode due to external change")
|
|
||||||
startPollingTimer(interval: rapidPollingInterval)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Within debounce window of key press - still update values but don't show HUD
|
|
||||||
print("🔇 Volume change within debounce window (\(timeSinceKeyPress)s) - skipping HUD")
|
|
||||||
}
|
|
||||||
|
|
||||||
oldVolume = newVolume
|
|
||||||
oldMuted = newMuted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkBrightnessChanges() {
|
|
||||||
guard Defaults[.enableBrightnessHUD] else { return }
|
|
||||||
guard NSScreen.screens.count > 0 else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let newBrightness = try SystemDisplayManager.getDisplayBrightness()
|
|
||||||
let brightnessChanged = !isAlmost(firstNumber: oldBrightness, secondNumber: newBrightness)
|
|
||||||
|
|
||||||
if brightnessChanged {
|
|
||||||
print("☀️ Brightness change detected: \(oldBrightness) → \(newBrightness)")
|
|
||||||
sendBrightnessNotification(value: newBrightness)
|
|
||||||
oldBrightness = newBrightness
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore brightness errors to reduce log spam
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isAlmost(firstNumber: Float, secondNumber: Float) -> Bool {
|
|
||||||
// Use a fixed sensitive threshold for volume changes
|
|
||||||
// 0.015 = 1.5% change required (sensitive enough for headphone increments)
|
|
||||||
let threshold: Float = 0.015
|
|
||||||
return abs(firstNumber - secondNumber) <= threshold
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Internal Notification Methods
|
|
||||||
|
|
||||||
private func sendVolumeNotification(value: Float) {
|
private func sendVolumeNotification(value: Float) {
|
||||||
coordinator?.toggleSneakPeek(
|
coordinator?.toggleSneakPeek(
|
||||||
|
|
@ -359,52 +109,6 @@ class SystemChangesObserver {
|
||||||
icon: ""
|
icon: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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<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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Defaults
|
import Defaults
|
||||||
import Combine
|
import Combine
|
||||||
import AppKit
|
|
||||||
|
|
||||||
class SystemHUDManager {
|
class SystemHUDManager {
|
||||||
static let shared = SystemHUDManager()
|
static let shared = SystemHUDManager()
|
||||||
|
|
@ -43,20 +42,20 @@ class SystemHUDManager {
|
||||||
guard let self = self, self.isSetupComplete, Defaults[.enableSystemHUD] else {
|
guard let self = self, self.isSetupComplete, Defaults[.enableSystemHUD] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// When volume HUD is enabled, ensure OSD is disabled
|
self.changesObserver?.update(
|
||||||
if change.newValue {
|
volumeEnabled: change.newValue,
|
||||||
self.disableSystemHUD()
|
brightnessEnabled: Defaults[.enableBrightnessHUD]
|
||||||
}
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] change in
|
||||||
guard let self = self, self.isSetupComplete, Defaults[.enableSystemHUD] else {
|
guard let self = self, self.isSetupComplete, Defaults[.enableSystemHUD] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// When brightness HUD is enabled, ensure OSD is disabled
|
self.changesObserver?.update(
|
||||||
if change.newValue {
|
volumeEnabled: Defaults[.enableVolumeHUD],
|
||||||
self.disableSystemHUD()
|
brightnessEnabled: change.newValue
|
||||||
}
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,13 +87,9 @@ class SystemHUDManager {
|
||||||
await stopSystemHUD() // Stop any existing observer
|
await stopSystemHUD() // Stop any existing observer
|
||||||
|
|
||||||
changesObserver = SystemChangesObserver(coordinator: coordinator)
|
changesObserver = SystemChangesObserver(coordinator: coordinator)
|
||||||
changesObserver?.startObserving()
|
let volumeEnabled = Defaults[.enableVolumeHUD]
|
||||||
|
let brightnessEnabled = Defaults[.enableBrightnessHUD]
|
||||||
// Disable system HUD if possible
|
changesObserver?.startObserving(volumeEnabled: volumeEnabled, brightnessEnabled: brightnessEnabled)
|
||||||
disableSystemHUD()
|
|
||||||
|
|
||||||
// Start event-driven OSD monitoring
|
|
||||||
startOSDMonitoring()
|
|
||||||
|
|
||||||
print("System HUD replacement started")
|
print("System HUD replacement started")
|
||||||
isSystemOperationInProgress = false
|
isSystemOperationInProgress = false
|
||||||
|
|
@ -108,120 +103,14 @@ class SystemHUDManager {
|
||||||
changesObserver?.stopObserving()
|
changesObserver?.stopObserving()
|
||||||
changesObserver = nil
|
changesObserver = nil
|
||||||
|
|
||||||
// Stop OSD monitoring
|
|
||||||
stopOSDMonitoring()
|
|
||||||
|
|
||||||
// Re-enable system HUD
|
|
||||||
enableOriginalSystemHUD()
|
|
||||||
|
|
||||||
print("System HUD replacement stopped")
|
print("System HUD replacement stopped")
|
||||||
isSystemOperationInProgress = false
|
isSystemOperationInProgress = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func disableSystemHUD() {
|
|
||||||
print("🔇 Disabling system HUD (OSDUIHelper running: \(SystemOSDManager.isOSDUIHelperRunning()))")
|
|
||||||
// Disable the system HUD using SystemOSDManager
|
|
||||||
SystemOSDManager.disableSystemHUD()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func enableOriginalSystemHUD() {
|
|
||||||
print("🔊 Re-enabling system HUD (OSDUIHelper running: \(SystemOSDManager.isOSDUIHelperRunning()))")
|
|
||||||
// Re-enable the system HUD using SystemOSDManager
|
|
||||||
SystemOSDManager.enableSystemHUD()
|
|
||||||
|
|
||||||
// Check status after a reasonable delay, using async to avoid blocking
|
|
||||||
Task {
|
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds instead of 1
|
|
||||||
let isRunning = await SystemOSDManager.isOSDUIHelperRunningAsync()
|
|
||||||
print("🔍 OSDUIHelper status after re-enable: \(isRunning ? "✅ Running" : "❌ Not running")")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Media Key Event Handling
|
|
||||||
|
|
||||||
/// Handle volume key press events from MediaKeyApplication
|
|
||||||
public func handleVolumeKeyEvent() {
|
|
||||||
changesObserver?.handleVolumeKeyEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle brightness key press events from MediaKeyApplication
|
|
||||||
public func handleBrightnessKeyEvent() {
|
|
||||||
changesObserver?.handleBrightnessKeyEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - OSD Monitoring
|
|
||||||
|
|
||||||
/// Start event-driven monitoring for OSDUIHelper process launches
|
|
||||||
@MainActor
|
|
||||||
private func startOSDMonitoring() {
|
|
||||||
stopOSDMonitoring() // Stop any existing observer
|
|
||||||
|
|
||||||
// Listen for process launch notifications
|
|
||||||
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)
|
|
||||||
.compactMap { notification -> NSRunningApplication? in
|
|
||||||
notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
|
|
||||||
}
|
|
||||||
.filter { app in
|
|
||||||
// Check if OSDUIHelper was launched
|
|
||||||
app.bundleIdentifier == "com.apple.OSDUIHelper" ||
|
|
||||||
app.localizedName?.contains("OSDUIHelper") == true
|
|
||||||
}
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Check if any HUD is enabled
|
|
||||||
let anyHUDEnabled = Defaults[.enableVolumeHUD] || Defaults[.enableBrightnessHUD]
|
|
||||||
guard anyHUDEnabled else { return }
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
print("⚠️ OSDUIHelper launched - re-disabling")
|
|
||||||
self.disableSystemHUD()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Also listen for screen wake events (OSDUIHelper often restarts after wake)
|
|
||||||
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.screensDidWakeNotification)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
let anyHUDEnabled = Defaults[.enableVolumeHUD] || Defaults[.enableBrightnessHUD]
|
|
||||||
guard anyHUDEnabled else { return }
|
|
||||||
|
|
||||||
// Check after a short delay to ensure system is ready
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
|
||||||
|
|
||||||
let isRunning = await SystemOSDManager.isOSDUIHelperRunningAsync()
|
|
||||||
if isRunning {
|
|
||||||
print("⚠️ OSDUIHelper detected running after wake - re-disabling")
|
|
||||||
self.disableSystemHUD()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
print("✅ OSD monitoring started (event-driven)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop OSD monitoring
|
|
||||||
@MainActor
|
|
||||||
private func stopOSDMonitoring() {
|
|
||||||
// Cancellables are automatically cleaned up
|
|
||||||
print("🛑 OSD monitoring stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
cancellables.removeAll()
|
cancellables.removeAll()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await stopSystemHUD()
|
await stopSystemHUD()
|
||||||
}
|
}
|
||||||
// Emergency restore is available via SystemHUDManager.emergencyRestoreSystemHUD() if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emergency restore function - call this if system HUD is not working
|
|
||||||
public static func emergencyRestoreSystemHUD() {
|
|
||||||
print("🚨 Emergency system HUD restore initiated")
|
|
||||||
SystemHUDDebugger.forceRestartOSDUIHelper()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
348
DynamicIsland/managers/SystemMediaControllers.swift
Normal file
348
DynamicIsland/managers/SystemMediaControllers.swift
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreAudio
|
||||||
|
import IOKit
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let systemVolumeDidChange = Notification.Name("DynamicIsland.systemVolumeDidChange")
|
||||||
|
static let systemBrightnessDidChange = Notification.Name("DynamicIsland.systemBrightnessDidChange")
|
||||||
|
static let systemAudioRouteDidChange = Notification.Name("DynamicIsland.systemAudioRouteDidChange")
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SystemVolumeController {
|
||||||
|
static let shared = SystemVolumeController()
|
||||||
|
|
||||||
|
var onVolumeChange: ((Float, Bool) -> Void)?
|
||||||
|
var onRouteChange: (() -> Void)?
|
||||||
|
|
||||||
|
private let callbackQueue = DispatchQueue(label: "com.dynamicisland.volume-listener")
|
||||||
|
private var currentDeviceID: AudioDeviceID = 0
|
||||||
|
private var listenersInstalled = false
|
||||||
|
private var volumeElement: AudioObjectPropertyElement?
|
||||||
|
private var muteElement: AudioObjectPropertyElement?
|
||||||
|
|
||||||
|
private let candidateElements: [AudioObjectPropertyElement] = [
|
||||||
|
kAudioObjectPropertyElementMain,
|
||||||
|
AudioObjectPropertyElement(1),
|
||||||
|
AudioObjectPropertyElement(2)
|
||||||
|
]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
currentDeviceID = resolveDefaultDevice()
|
||||||
|
refreshPropertyElements()
|
||||||
|
installDefaultDeviceListener()
|
||||||
|
installVolumeListeners(for: currentDeviceID)
|
||||||
|
notifyCurrentState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
// Listeners are installed during init, nothing else required.
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
// We keep listeners alive for the app lifetime; clearing closures prevents UI updates.
|
||||||
|
onVolumeChange = nil
|
||||||
|
onRouteChange = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjust(by delta: Float) {
|
||||||
|
var newValue = currentVolume + delta
|
||||||
|
newValue = max(0, min(1, newValue))
|
||||||
|
setVolume(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleMute() {
|
||||||
|
setMuted(!isMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentVolume: Float {
|
||||||
|
getVolume()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMuted: Bool {
|
||||||
|
getMuteState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(_ value: Float) {
|
||||||
|
var volume = max(0, min(1, value))
|
||||||
|
let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Failed to set volume: \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMuted(_ muted: Bool) {
|
||||||
|
var muteFlag: UInt32 = muted ? 1 : 0
|
||||||
|
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Failed to set mute state: \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func resolveDefaultDevice() -> AudioDeviceID {
|
||||||
|
var deviceID = AudioDeviceID()
|
||||||
|
var size = UInt32(MemoryLayout.size(ofValue: deviceID))
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID)
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Unable to fetch default audio device: \(status)")
|
||||||
|
}
|
||||||
|
return deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installDefaultDeviceListener() {
|
||||||
|
guard !listenersInstalled else { return }
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
let status = AudioObjectAddPropertyListenerBlock(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address,
|
||||||
|
callbackQueue
|
||||||
|
) { [weak self] _, _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.handleDefaultDeviceChanged()
|
||||||
|
}
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Failed to install default device listener: \(status)")
|
||||||
|
}
|
||||||
|
listenersInstalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installVolumeListeners(for deviceID: AudioDeviceID) {
|
||||||
|
if let element = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: deviceID) {
|
||||||
|
volumeElement = element
|
||||||
|
var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element)
|
||||||
|
AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in
|
||||||
|
self?.notifyCurrentState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let element = resolveElement(selector: kAudioDevicePropertyMute, deviceID: deviceID) {
|
||||||
|
muteElement = element
|
||||||
|
var address = makeAddress(selector: kAudioDevicePropertyMute, element: element)
|
||||||
|
AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in
|
||||||
|
self?.notifyCurrentState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDefaultDeviceChanged() {
|
||||||
|
callbackQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.currentDeviceID = self.resolveDefaultDevice()
|
||||||
|
self.refreshPropertyElements()
|
||||||
|
self.installVolumeListeners(for: self.currentDeviceID)
|
||||||
|
self.notifyCurrentState()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onRouteChange?()
|
||||||
|
NotificationCenter.default.post(name: .systemAudioRouteDidChange, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyCurrentState() {
|
||||||
|
let volume = getVolume()
|
||||||
|
let muted = getMuteState()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onVolumeChange?(volume, muted)
|
||||||
|
NotificationCenter.default.post(name: .systemVolumeDidChange, object: nil, userInfo: ["value": volume, "muted": muted])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getVolume() -> Float {
|
||||||
|
var volume = Float32(0)
|
||||||
|
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Unable to fetch volume: \(status)")
|
||||||
|
}
|
||||||
|
return volume
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getMuteState() -> Bool {
|
||||||
|
var mute: UInt32 = 0
|
||||||
|
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
|
||||||
|
if status != noErr {
|
||||||
|
// Some devices do not support mute; treat as not muted
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mute != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshPropertyElements() {
|
||||||
|
volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID)
|
||||||
|
muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveElement(selector: AudioObjectPropertySelector, deviceID: AudioDeviceID) -> AudioObjectPropertyElement? {
|
||||||
|
for element in candidateElements {
|
||||||
|
var address = makeAddress(selector: selector, element: element)
|
||||||
|
if propertyExists(deviceID: deviceID, address: &address) {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preferredElements(for selector: AudioObjectPropertySelector) -> [AudioObjectPropertyElement] {
|
||||||
|
if let cached = cachedElement(for: selector) {
|
||||||
|
return [cached] + candidateElements.filter { $0 != cached }
|
||||||
|
}
|
||||||
|
return candidateElements
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cachedElement(for selector: AudioObjectPropertySelector) -> AudioObjectPropertyElement? {
|
||||||
|
switch selector {
|
||||||
|
case kAudioDevicePropertyVolumeScalar:
|
||||||
|
return volumeElement
|
||||||
|
case kAudioDevicePropertyMute:
|
||||||
|
return muteElement
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cache(element: AudioObjectPropertyElement, for selector: AudioObjectPropertySelector) {
|
||||||
|
switch selector {
|
||||||
|
case kAudioDevicePropertyVolumeScalar:
|
||||||
|
volumeElement = element
|
||||||
|
case kAudioDevicePropertyMute:
|
||||||
|
muteElement = element
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAddress(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement) -> AudioObjectPropertyAddress {
|
||||||
|
AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: element
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func propertyExists(deviceID: AudioDeviceID, address: inout AudioObjectPropertyAddress) -> Bool {
|
||||||
|
withUnsafePointer(to: &address) { pointer in
|
||||||
|
AudioObjectHasProperty(deviceID, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getData<T>(selector: AudioObjectPropertySelector, data: inout T) -> OSStatus {
|
||||||
|
var lastStatus: OSStatus = kAudioHardwareUnspecifiedError
|
||||||
|
for element in preferredElements(for: selector) {
|
||||||
|
var address = makeAddress(selector: selector, element: element)
|
||||||
|
guard propertyExists(deviceID: currentDeviceID, address: &address) else { continue }
|
||||||
|
var size = UInt32(MemoryLayout<T>.size)
|
||||||
|
lastStatus = AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
|
||||||
|
if lastStatus == noErr {
|
||||||
|
cache(element: element, for: selector)
|
||||||
|
return lastStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setData<T>(selector: AudioObjectPropertySelector, data: inout T) -> OSStatus {
|
||||||
|
var lastStatus: OSStatus = kAudioHardwareUnspecifiedError
|
||||||
|
for element in preferredElements(for: selector) {
|
||||||
|
var address = makeAddress(selector: selector, element: element)
|
||||||
|
guard propertyExists(deviceID: currentDeviceID, address: &address) else { continue }
|
||||||
|
let size = UInt32(MemoryLayout<T>.size)
|
||||||
|
lastStatus = AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data)
|
||||||
|
if lastStatus == noErr {
|
||||||
|
cache(element: element, for: selector)
|
||||||
|
return lastStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SystemBrightnessController {
|
||||||
|
static let shared = SystemBrightnessController()
|
||||||
|
|
||||||
|
var onBrightnessChange: ((Float) -> Void)?
|
||||||
|
|
||||||
|
private let notificationCenter = NotificationCenter.default
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
private var notificationsInstalled = false
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
registerExternalNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
notifyCurrentBrightness()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
onBrightnessChange = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjust(by delta: Float) {
|
||||||
|
setBrightness(currentBrightness + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBrightness(_ value: Float) {
|
||||||
|
let clamped = max(0, min(1, value))
|
||||||
|
guard let service = displayService() else { return }
|
||||||
|
IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
|
||||||
|
IOObjectRelease(service)
|
||||||
|
notifyCurrentBrightness()
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentBrightness: Float {
|
||||||
|
guard let service = displayService() else { return 0.5 }
|
||||||
|
var brightness: Float = 0
|
||||||
|
let result = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, &brightness)
|
||||||
|
IOObjectRelease(service)
|
||||||
|
if result != kIOReturnSuccess {
|
||||||
|
return 0.5
|
||||||
|
}
|
||||||
|
return brightness
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyCurrentBrightness() {
|
||||||
|
let brightness = currentBrightness
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onBrightnessChange?(brightness)
|
||||||
|
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": brightness])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func displayService() -> io_service_t? {
|
||||||
|
var iterator = io_iterator_t()
|
||||||
|
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IODisplayConnect"), &iterator) == KERN_SUCCESS else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let service = IOIteratorNext(iterator)
|
||||||
|
IOObjectRelease(iterator)
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerExternalNotifications() {
|
||||||
|
guard !notificationsInstalled else { return }
|
||||||
|
let names = [
|
||||||
|
Notification.Name("com.apple.BezelEngine.BrightnessChanged"),
|
||||||
|
Notification.Name("com.apple.BezelServices.BrightnessChanged"),
|
||||||
|
Notification.Name("com.apple.controlcenter.display.brightness")
|
||||||
|
]
|
||||||
|
observers = names.map { name in
|
||||||
|
DistributedNotificationCenter.default().addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||||
|
self?.notifyCurrentBrightness()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notificationsInstalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue