Merge pull request #84 from Ebullioscopic/feature/hud

Feature/hud
This commit is contained in:
Hariharan Mudaliar 2025-11-07 00:08:18 +05:30 • committed by GitHub
commit da3b67142e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 563 additions and 49 deletions

View file

@ -1,5 +1,5 @@
{ {
"originHash" : "6e10ca48df0618916616b81379994d6c30d5716d19178aefa050c983082272ce", "originHash" : "ac662b7c30c44a46342d90fd2f894c1a70161e39d60b85657ca39c859e894c44",
"pins" : [ "pins" : [
{ {
"identity" : "defaults", "identity" : "defaults",

View file

@ -24,6 +24,7 @@ struct InlineHUD: View {
@Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator @Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
@Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText @Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
@Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee @Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
@Default(.enableMinimalisticUI) var enableMinimalisticUI
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared @ObservedObject var bluetoothManager = BluetoothAudioManager.shared
@State private var displayName: String = "" @State private var displayName: String = ""
@ -33,35 +34,54 @@ struct InlineHUD: View {
let hasBatteryLevel = value > 0 let hasBatteryLevel = value > 0
let baseInfoWidth: CGFloat = { let baseInfoWidth: CGFloat = {
if type == .bluetoothAudio { guard type == .bluetoothAudio else { return 100 }
return showBluetoothDeviceNameMarquee ? 140 : 88 if showBluetoothDeviceNameMarquee {
return enableMinimalisticUI ? 128 : 140
} }
return 100 return enableMinimalisticUI ? 64 : 72
}() }()
let infoWidth: CGFloat = { let infoWidth: CGFloat = {
var width = baseInfoWidth + gestureProgress / 2 var width = baseInfoWidth + gestureProgress / 2
if !hoverAnimation { width -= 8 } if !hoverAnimation { width -= 8 }
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothDeviceNameMarquee ? 120 : 82) : 88 let minimum: CGFloat = {
guard type == .bluetoothAudio else { return 88 }
if showBluetoothDeviceNameMarquee {
return enableMinimalisticUI ? 112 : 120
}
return enableMinimalisticUI ? 56 : 68
}()
return max(width, minimum) return max(width, minimum)
}() }()
let baseTrailingWidth: CGFloat = { let baseTrailingWidth: CGFloat = {
if type == .bluetoothAudio { guard type == .bluetoothAudio else { return 100 }
if !hasBatteryLevel { if !hasBatteryLevel {
return showBluetoothDeviceNameMarquee ? 118 : 88 return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88)
} }
if useCircularIndicator { if useCircularIndicator {
return showBluetoothBatteryPercentageText ? 120 : 96 return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 108 : 120) : (enableMinimalisticUI ? 72 : 84)
} else {
return showBluetoothBatteryPercentageText ? 136 : 108
} }
}
return 100 return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 118 : 136) : (enableMinimalisticUI ? 92 : 108)
}() }()
let trailingWidth: CGFloat = { let trailingWidth: CGFloat = {
var width = baseTrailingWidth + gestureProgress / 2 var width = baseTrailingWidth + gestureProgress / 2
if !hoverAnimation { width -= 8 } if !hoverAnimation { width -= 8 }
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothBatteryPercentageText ? 110 : 92) : 90 let minimum: CGFloat = {
guard type == .bluetoothAudio else { return 90 }
if !hasBatteryLevel {
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 96 : 110) : (enableMinimalisticUI ? 62 : 88)
}
if useCircularIndicator {
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 92 : 110) : (enableMinimalisticUI ? 56 : 72)
}
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 104 : 120) : (enableMinimalisticUI ? 72 : 90)
}()
return max(width, minimum) return max(width, minimum)
}() }()
@ -161,7 +181,14 @@ struct InlineHUD: View {
.contentTransition(.interpolate) .contentTransition(.interpolate)
} else if (type == .bluetoothAudio) { } else if (type == .bluetoothAudio) {
if hasBatteryLevel { if hasBatteryLevel {
HStack(spacing: useCircularIndicator ? 8 : 6) { let indicatorSpacing: CGFloat = {
if useCircularIndicator {
return showBluetoothBatteryPercentageText ? 8 : 2
}
return showBluetoothBatteryPercentageText ? 6 : 4
}()
HStack(spacing: indicatorSpacing) {
if useCircularIndicator { if useCircularIndicator {
CircularBatteryIndicator( CircularBatteryIndicator(
value: value, value: value,
@ -221,7 +248,7 @@ struct InlineHUD: View {
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} }
} }
.padding(.trailing, 4) .padding(.trailing, trailingWidth > 0 ? 4 : 0)
.frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center) .frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
} }
.frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center) .frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)

View file

@ -13,6 +13,7 @@ import Defaults
import SwiftUI import SwiftUI
import IOBluetooth import IOBluetooth
import IOKit import IOKit
import CoreBluetooth
/// Manages detection and monitoring of Bluetooth audio device connections /// Manages detection and monitoring of Bluetooth audio device connections
class BluetoothAudioManager: ObservableObject { class BluetoothAudioManager: ObservableObject {
@ -29,6 +30,8 @@ class BluetoothAudioManager: ObservableObject {
private let coordinator = DynamicIslandViewCoordinator.shared private let coordinator = DynamicIslandViewCoordinator.shared
private var pollingTimer: Timer? private var pollingTimer: Timer?
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth" private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
private let batteryReader = BluetoothLEBatteryReader()
private var isLiveBatteryRefreshInFlight = false
@Published private(set) var batteryStatus: [String: String] = [:] @Published private(set) var batteryStatus: [String: String] = [:]
@ -411,17 +414,24 @@ class BluetoothAudioManager: ObservableObject {
} }
} }
private func refreshBatteryLevelsForConnectedDevices() { private func refreshBatteryLevelsForConnectedDevices(forceCacheRefresh: Bool = true) {
if forceCacheRefresh {
updateBatteryStatuses(force: true) updateBatteryStatuses(force: true)
}
applyConnectedDeviceBatteryLevels()
triggerLiveBatteryRefreshIfNeeded()
}
private func applyConnectedDeviceBatteryLevels() {
guard !connectedDevices.isEmpty else {
lastConnectedDevice = nil
return
}
var updatedDevices: [BluetoothAudioDevice] = [] var updatedDevices: [BluetoothAudioDevice] = []
for device in connectedDevices { for device in connectedDevices {
let refreshedLevel = let refreshedLevel = bestBatteryLevel(for: device)
batteryLevelFromRegistry(forAddress: device.address)
?? batteryLevelFromRegistry(forName: device.name)
?? batteryLevelFromDefaults(forAddress: device.address)
?? batteryLevelFromDefaults(forName: device.name)
?? device.batteryLevel
let updatedDevice = device.withBatteryLevel(refreshedLevel) let updatedDevice = device.withBatteryLevel(refreshedLevel)
updatedDevices.append(updatedDevice) updatedDevices.append(updatedDevice)
@ -433,11 +443,189 @@ class BluetoothAudioManager: ObservableObject {
} }
connectedDevices = updatedDevices connectedDevices = updatedDevices
if let last = connectedDevices.last { if let last = updatedDevices.last {
lastConnectedDevice = last lastConnectedDevice = last
} }
} }
private func bestBatteryLevel(for device: BluetoothAudioDevice) -> Int? {
batteryLevelFromRegistry(forAddress: device.address)
?? batteryLevelFromRegistry(forName: device.name)
?? batteryLevelFromDefaults(forAddress: device.address)
?? batteryLevelFromDefaults(forName: device.name)
?? device.batteryLevel
}
private func triggerLiveBatteryRefreshIfNeeded() {
guard !connectedDevices.isEmpty else { return }
guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return }
guard !isLiveBatteryRefreshInFlight else { return }
let lookups = coreBluetoothLookups(for: connectedDevices)
guard !lookups.isEmpty else { return }
isLiveBatteryRefreshInFlight = true
batteryReader.fetchBatteryLevels(for: lookups) { [weak self] results in
guard let self else { return }
DispatchQueue.main.async {
self.isLiveBatteryRefreshInFlight = false
self.handleLiveBatteryResults(results)
}
}
}
private func coreBluetoothLookups(for devices: [BluetoothAudioDevice]) -> [BluetoothLEBatteryReader.Lookup] {
let snapshot = coreBluetoothCacheSnapshot()
guard snapshot.hasEntries else { return [] }
var lookups: [BluetoothLEBatteryReader.Lookup] = []
var seenUUIDs: Set<UUID> = []
for device in devices {
let normalizedAddress = normalizeBluetoothIdentifier(device.address)
let normalizedName = normalizeProductName(device.name)
guard !normalizedAddress.isEmpty || !normalizedName.isEmpty else { continue }
let uuid = snapshot.byAddress[normalizedAddress]
?? snapshot.byName[normalizedName]
guard let uuid, !seenUUIDs.contains(uuid) else { continue }
seenUUIDs.insert(uuid)
let canonicalName = snapshot.namesByUUID[uuid] ?? normalizedName
lookups.append(
.init(
uuid: uuid,
addressKey: normalizedAddress.isEmpty ? nil : normalizedAddress,
nameKey: canonicalName.isEmpty ? nil : canonicalName
)
)
}
return lookups
}
private func handleLiveBatteryResults(_ results: [BluetoothLEBatteryReader.Result]) {
guard !results.isEmpty else { return }
var didUpdate = false
for result in results {
let level = clampBatteryPercentage(result.level)
if let addressKey = result.addressKey, !addressKey.isEmpty {
let previous = batteryStatusByAddress[addressKey] ?? -1
if level > previous {
batteryStatusByAddress[addressKey] = level
batteryStatus[addressKey] = String(level)
didUpdate = true
}
}
if let nameKey = result.nameKey, !nameKey.isEmpty {
let previous = batteryStatusByName[nameKey] ?? -1
if level > previous {
batteryStatusByName[nameKey] = level
didUpdate = true
}
}
}
guard didUpdate else { return }
applyConnectedDeviceBatteryLevels()
if let lastLevel = lastConnectedDevice?.batteryLevel {
updateActiveBluetoothHUDBattery(with: lastLevel)
} else if let fallbackLevel = connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel {
updateActiveBluetoothHUDBattery(with: fallbackLevel)
}
}
private func updateActiveBluetoothHUDBattery(with level: Int?) {
guard let level else { return }
DispatchQueue.main.async {
guard self.coordinator.sneakPeek.show,
self.coordinator.sneakPeek.type == .bluetoothAudio else { return }
self.coordinator.sneakPeek.value = CGFloat(level) / 100.0
}
}
private func coreBluetoothCacheSnapshot() -> CoreBluetoothCacheSnapshot {
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else {
return .empty
}
var byAddress: [String: UUID] = [:]
var byName: [String: UUID] = [:]
var namesByUUID: [UUID: String] = [:]
for (uuidString, payload) in coreCache {
guard let uuid = UUID(uuidString: uuidString) else { continue }
let addressKeys = ["DeviceAddress", "Address", "BD_ADDR", "device_address"]
for key in addressKeys {
if let value = payload[key], let normalized = normalizeBluetoothIdentifier(from: value) {
byAddress[normalized] = uuid
}
}
if let serialValue = payload["SerialNumber"], let normalizedSerial = normalizeBluetoothIdentifier(from: serialValue) {
byAddress[normalizedSerial] = uuid
}
let nameKeys = ["Name", "DeviceName", "ProductName", "Product", "device_name"]
for key in nameKeys {
if let value = payload[key], let normalizedName = normalizeProductName(from: value) {
byName[normalizedName] = uuid
namesByUUID[uuid] = normalizedName
}
}
}
return CoreBluetoothCacheSnapshot(byAddress: byAddress, byName: byName, namesByUUID: namesByUUID)
}
private func normalizeBluetoothIdentifier(from value: Any) -> String? {
if let string = value as? String {
let normalized = normalizeBluetoothIdentifier(string)
return normalized.isEmpty ? nil : normalized
}
if let data = value as? Data,
let ascii = String(data: data, encoding: .utf8) {
let normalized = normalizeBluetoothIdentifier(ascii)
return normalized.isEmpty ? nil : normalized
}
return nil
}
private func normalizeProductName(from value: Any) -> String? {
if let string = value as? String {
let normalized = normalizeProductName(string)
return normalized.isEmpty ? nil : normalized
}
if let data = value as? Data,
let ascii = String(data: data, encoding: .utf8) {
let normalized = normalizeProductName(ascii)
return normalized.isEmpty ? nil : normalized
}
return nil
}
private struct CoreBluetoothCacheSnapshot {
let byAddress: [String: UUID]
let byName: [String: UUID]
let namesByUUID: [UUID: String]
var hasEntries: Bool {
!byAddress.isEmpty || !byName.isEmpty
}
static let empty = CoreBluetoothCacheSnapshot(byAddress: [:], byName: [:], namesByUUID: [:])
}
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? { private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
guard let address, !address.isEmpty else { return nil } guard let address, !address.isEmpty else { return nil }
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil } guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
@ -1004,11 +1192,7 @@ class BluetoothAudioManager: ObservableObject {
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD") print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
// Convert battery percentage to 0.0-1.0 value // Convert battery percentage to 0.0-1.0 value
let reportedBattery = device.batteryLevel let reportedBattery = bestBatteryLevel(for: device)
?? batteryLevelFromRegistry(forAddress: device.address)
?? batteryLevelFromRegistry(forName: device.name)
?? batteryLevelFromDefaults(forAddress: device.address)
?? batteryLevelFromDefaults(forName: device.name)
let batteryValue: CGFloat = if let battery = reportedBattery { let batteryValue: CGFloat = if let battery = reportedBattery {
CGFloat(clampBatteryPercentage(battery)) / 100.0 CGFloat(clampBatteryPercentage(battery)) / 100.0
@ -1048,6 +1232,262 @@ class BluetoothAudioManager: ObservableObject {
} }
} }
// MARK: - CoreBluetooth Battery Reader
private final class BluetoothLEBatteryReader: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
struct Lookup {
let uuid: UUID
let addressKey: String?
let nameKey: String?
}
struct Result {
let uuid: UUID
let level: Int
let addressKey: String?
let nameKey: String?
}
private enum State {
case idle
case requesting
}
private static let batteryServiceUUID = CBUUID(string: "180F")
private static let batteryCharacteristicUUID = CBUUID(string: "2A19")
private let timeoutInterval: TimeInterval = 6.0
private var central: CBCentralManager!
private var state: State = .idle
private var pendingLookups: [Lookup] = []
private var lookupByUUID: [UUID: Lookup] = [:]
private var completion: (([Result]) -> Void)?
private var pendingPeripherals: [UUID: CBPeripheral] = [:]
private var results: [UUID: Result] = [:]
private var missingUUIDs: Set<UUID> = []
private var timeoutWorkItem: DispatchWorkItem?
override init() {
super.init()
central = CBCentralManager(delegate: self, queue: nil)
}
func fetchBatteryLevels(for lookups: [Lookup], completion: @escaping ([Result]) -> Void) {
guard !lookups.isEmpty else {
completion([])
return
}
guard state == .idle else {
completion([])
return
}
state = .requesting
pendingLookups = lookups
lookupByUUID = Dictionary(uniqueKeysWithValues: lookups.map { ($0.uuid, $0) })
self.completion = completion
results.removeAll()
pendingPeripherals.removeAll()
missingUUIDs = Set(lookups.map { $0.uuid })
switch central.state {
case .poweredOn:
startRequest()
case .unauthorized, .unsupported, .poweredOff:
complete(with: [])
default:
break
}
}
// MARK: - CBCentralManagerDelegate
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard state == .requesting else { return }
switch central.state {
case .poweredOn:
startRequest()
case .unauthorized, .unsupported, .poweredOff:
complete(with: [])
default:
break
}
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
peripheral.discoverServices([Self.batteryServiceUUID])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
markPeripheralFinished(peripheral.identifier)
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
guard state == .requesting else { return }
markPeripheralFinished(peripheral.identifier)
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard state == .requesting else { return }
guard missingUUIDs.contains(peripheral.identifier) else { return }
missingUUIDs.remove(peripheral.identifier)
configurePeripheral(peripheral)
}
// MARK: - CBPeripheralDelegate
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard state == .requesting else { return }
if let error {
print("🎧 [BluetoothLEBatteryReader] Service discovery failed: \(error.localizedDescription)")
markPeripheralFinished(peripheral.identifier)
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == Self.batteryServiceUUID }) else {
markPeripheralFinished(peripheral.identifier)
return
}
peripheral.discoverCharacteristics([Self.batteryCharacteristicUUID], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard state == .requesting else { return }
if let error {
print("🎧 [BluetoothLEBatteryReader] Characteristic discovery failed: \(error.localizedDescription)")
markPeripheralFinished(peripheral.identifier)
return
}
guard let characteristic = service.characteristics?.first(where: { $0.uuid == Self.batteryCharacteristicUUID }) else {
markPeripheralFinished(peripheral.identifier)
return
}
peripheral.readValue(for: characteristic)
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard state == .requesting else { return }
defer { markPeripheralFinished(peripheral.identifier) }
if let error {
print("🎧 [BluetoothLEBatteryReader] Battery read failed: \(error.localizedDescription)")
return
}
guard let data = characteristic.value, let byte = data.first, let lookup = lookupByUUID[peripheral.identifier] else {
return
}
let level = Int(byte)
results[peripheral.identifier] = Result(
uuid: peripheral.identifier,
level: level,
addressKey: lookup.addressKey,
nameKey: lookup.nameKey
)
}
// MARK: - Helpers
private func startRequest() {
central.stopScan()
let identifiers = Array(missingUUIDs)
if !identifiers.isEmpty {
let peripherals = central.retrievePeripherals(withIdentifiers: identifiers)
for peripheral in peripherals {
missingUUIDs.remove(peripheral.identifier)
configurePeripheral(peripheral)
}
}
let connectedPeripherals = central.retrieveConnectedPeripherals(withServices: [Self.batteryServiceUUID])
for peripheral in connectedPeripherals where missingUUIDs.contains(peripheral.identifier) {
missingUUIDs.remove(peripheral.identifier)
configurePeripheral(peripheral)
}
if !missingUUIDs.isEmpty {
central.scanForPeripherals(withServices: [Self.batteryServiceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
}
if pendingPeripherals.isEmpty && missingUUIDs.isEmpty {
complete(with: Array(results.values))
return
}
scheduleTimeout()
}
private func configurePeripheral(_ peripheral: CBPeripheral) {
pendingPeripherals[peripheral.identifier] = peripheral
peripheral.delegate = self
switch peripheral.state {
case .connected:
peripheral.discoverServices([Self.batteryServiceUUID])
default:
central.connect(peripheral, options: nil)
}
}
private func markPeripheralFinished(_ identifier: UUID) {
pendingPeripherals.removeValue(forKey: identifier)
missingUUIDs.remove(identifier)
if missingUUIDs.isEmpty {
central.stopScan()
}
if pendingPeripherals.isEmpty && missingUUIDs.isEmpty {
complete(with: Array(results.values))
}
}
private func scheduleTimeout() {
cancelTimeout()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.complete(with: Array(self.results.values))
}
timeoutWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem)
}
private func cancelTimeout() {
timeoutWorkItem?.cancel()
timeoutWorkItem = nil
}
private func complete(with results: [Result]) {
guard state == .requesting else { return }
cancelTimeout()
central.stopScan()
state = .idle
pendingPeripherals.removeAll()
missingUUIDs.removeAll()
pendingLookups.removeAll()
lookupByUUID.removeAll()
let completion = self.completion
self.completion = nil
self.results.removeAll()
completion?(results)
}
}
// MARK: - Models // MARK: - Models
struct BluetoothAudioDevice: Identifiable { struct BluetoothAudioDevice: Identifiable {

View file

@ -17,6 +17,11 @@ enum MediaKeyDirection {
case down case down
} }
enum MediaKeyStep {
case standard
case fine
}
struct MediaKeyConfiguration { struct MediaKeyConfiguration {
var interceptVolume: Bool var interceptVolume: Bool
var interceptBrightness: Bool var interceptBrightness: Bool
@ -25,8 +30,8 @@ struct MediaKeyConfiguration {
} }
protocol MediaKeyInterceptorDelegate: AnyObject { protocol MediaKeyInterceptorDelegate: AnyObject {
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveVolumeCommand direction: MediaKeyDirection, isRepeat: Bool) func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveVolumeCommand direction: MediaKeyDirection, step: MediaKeyStep, isRepeat: Bool)
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveBrightnessCommand direction: MediaKeyDirection, isRepeat: Bool) func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveBrightnessCommand direction: MediaKeyDirection, step: MediaKeyStep, isRepeat: Bool)
func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor) func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor)
} }
@ -144,6 +149,7 @@ final class MediaKeyInterceptor {
let keyFlags = data1 & 0x0000FFFF let keyFlags = data1 & 0x0000FFFF
let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA // 0xA = keyDown, 0xB = keyUp let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA // 0xA = keyDown, 0xB = keyUp
let isRepeat = (keyFlags & 0x0001) == 1 let isRepeat = (keyFlags & 0x0001) == 1
let step = step(for: nsEvent)
guard keyState else { guard keyState else {
// Swallow key-up events only when intercepting, otherwise let them pass through // Swallow key-up events only when intercepting, otherwise let them pass through
@ -156,11 +162,11 @@ final class MediaKeyInterceptor {
switch Int32(keyCode) { switch Int32(keyCode) {
case NX_KEYTYPE_SOUND_UP: case NX_KEYTYPE_SOUND_UP:
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) } guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .up, isRepeat: isRepeat) delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .up, step: step, isRepeat: isRepeat)
return nil return nil
case NX_KEYTYPE_SOUND_DOWN: case NX_KEYTYPE_SOUND_DOWN:
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) } guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .down, isRepeat: isRepeat) delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .down, step: step, isRepeat: isRepeat)
return nil return nil
case NX_KEYTYPE_MUTE: case NX_KEYTYPE_MUTE:
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) } guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
@ -168,11 +174,11 @@ final class MediaKeyInterceptor {
return nil return nil
case NX_KEYTYPE_BRIGHTNESS_UP: case NX_KEYTYPE_BRIGHTNESS_UP:
guard configuration.interceptBrightness else { return Unmanaged.passUnretained(cgEvent) } guard configuration.interceptBrightness else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .up, isRepeat: isRepeat) delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .up, step: step, isRepeat: isRepeat)
return nil return nil
case NX_KEYTYPE_BRIGHTNESS_DOWN: case NX_KEYTYPE_BRIGHTNESS_DOWN:
guard configuration.interceptBrightness else { return Unmanaged.passUnretained(cgEvent) } guard configuration.interceptBrightness else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .down, isRepeat: isRepeat) delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .down, step: step, isRepeat: isRepeat)
return nil return nil
default: default:
return Unmanaged.passUnretained(cgEvent) return Unmanaged.passUnretained(cgEvent)
@ -189,6 +195,14 @@ final class MediaKeyInterceptor {
return false return false
} }
} }
private func step(for event: NSEvent) -> MediaKeyStep {
let modifiers = event.modifierFlags
if modifiers.contains(.option) && modifiers.contains(.shift) {
return .fine
}
return .standard
}
} }
#if canImport(ApplicationServices) #if canImport(ApplicationServices)

View file

@ -8,8 +8,9 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
private let brightnessController = SystemBrightnessController.shared private let brightnessController = SystemBrightnessController.shared
private let mediaKeyInterceptor = MediaKeyInterceptor.shared private let mediaKeyInterceptor = MediaKeyInterceptor.shared
private let volumeStep: Float = 1.0 / 16.0 private let standardVolumeStep: Float = 1.0 / 16.0
private let brightnessStep: Float = 1.0 / 16.0 private let standardBrightnessStep: Float = 1.0 / 16.0
private let fineStepDivisor: Float = 4.0
private var volumeEnabled = false private var volumeEnabled = false
private var brightnessEnabled = false private var brightnessEnabled = false
@ -73,9 +74,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
// MARK: - MediaKeyInterceptorDelegate // MARK: - MediaKeyInterceptorDelegate
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveVolumeCommand direction: MediaKeyDirection, isRepeat: Bool) { func mediaKeyInterceptor(
_ interceptor: MediaKeyInterceptor,
didReceiveVolumeCommand direction: MediaKeyDirection,
step: MediaKeyStep,
isRepeat: Bool
) {
guard volumeEnabled else { return } guard volumeEnabled else { return }
let delta = (direction == .up ? volumeStep : -volumeStep) let baseStep = stepSize(for: step, base: standardVolumeStep)
let delta = direction == .up ? baseStep : -baseStep
volumeController.adjust(by: delta) volumeController.adjust(by: delta)
} }
@ -84,9 +91,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
volumeController.toggleMute() volumeController.toggleMute()
} }
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveBrightnessCommand direction: MediaKeyDirection, isRepeat: Bool) { func mediaKeyInterceptor(
_ interceptor: MediaKeyInterceptor,
didReceiveBrightnessCommand direction: MediaKeyDirection,
step: MediaKeyStep,
isRepeat: Bool
) {
guard brightnessEnabled else { return } guard brightnessEnabled else { return }
let delta = (direction == .up ? brightnessStep : -brightnessStep) let baseStep = stepSize(for: step, base: standardBrightnessStep)
let delta = direction == .up ? baseStep : -baseStep
brightnessController.adjust(by: delta) brightnessController.adjust(by: delta)
} }
@ -114,4 +127,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
} }
private extension SystemChangesObserver {
func stepSize(for step: MediaKeyStep, base: Float) -> Float {
switch step {
case .standard:
return base
case .fine:
return base / fineStepDivisor
}
}
}

View file

@ -445,8 +445,11 @@ final class SystemBrightnessController {
private var brightnessAnimationStart: Float = 0 private var brightnessAnimationStart: Float = 0
private var brightnessAnimationTarget: Float = 0 private var brightnessAnimationTarget: Float = 0
private var brightnessAnimationStartDate: Date? private var brightnessAnimationStartDate: Date?
private let brightnessAnimationDuration: TimeInterval = 0.18 private var currentBrightnessAnimationDuration: TimeInterval = 0.18
private let brightnessAnimationSteps = 10 private let brightnessAnimationSteps = 10
private let minimumBrightnessAnimationDuration: TimeInterval = 0.08
private let maximumBrightnessAnimationDuration: TimeInterval = 0.3
private let brightnessAnimationDurationScale: TimeInterval = 1.6
private var lastEmittedBrightness: Float = 0.5 private var lastEmittedBrightness: Float = 0.5
private init() { private init() {
@ -507,8 +510,9 @@ final class SystemBrightnessController {
brightnessAnimationStart = start brightnessAnimationStart = start
brightnessAnimationTarget = target brightnessAnimationTarget = target
brightnessAnimationStartDate = Date() brightnessAnimationStartDate = Date()
currentBrightnessAnimationDuration = animationDuration(forDelta: abs(target - start))
let interval = brightnessAnimationDuration / Double(brightnessAnimationSteps) let interval = currentBrightnessAnimationDuration / Double(brightnessAnimationSteps)
let timer = Timer(timeInterval: interval, repeats: true) { [weak self] timer in let timer = Timer(timeInterval: interval, repeats: true) { [weak self] timer in
guard let self else { return } guard let self else { return }
guard let startDate = self.brightnessAnimationStartDate else { guard let startDate = self.brightnessAnimationStartDate else {
@ -517,7 +521,7 @@ final class SystemBrightnessController {
return return
} }
let elapsed = Date().timeIntervalSince(startDate) let elapsed = Date().timeIntervalSince(startDate)
let progress = min(elapsed / self.brightnessAnimationDuration, 1) let progress = min(elapsed / self.currentBrightnessAnimationDuration, 1)
let eased = self.ease(progress) let eased = self.ease(progress)
let value = self.brightnessAnimationStart + (self.brightnessAnimationTarget - self.brightnessAnimationStart) * Float(eased) let value = self.brightnessAnimationStart + (self.brightnessAnimationTarget - self.brightnessAnimationStart) * Float(eased)
self.applyBrightness(value) self.applyBrightness(value)
@ -532,6 +536,11 @@ final class SystemBrightnessController {
timer.fire() timer.fire()
} }
private func animationDuration(forDelta delta: Float) -> TimeInterval {
let scaled = minimumBrightnessAnimationDuration + TimeInterval(delta) * brightnessAnimationDurationScale
return min(maximumBrightnessAnimationDuration, max(minimumBrightnessAnimationDuration, scaled))
}
private func applyBrightness(_ value: Float) { private func applyBrightness(_ value: Float) {
let clamped = max(0, min(1, value)) let clamped = max(0, min(1, value))
if setBrightnessViaDisplayServices(clamped) { if setBrightnessViaDisplayServices(clamped) {