mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
commit
da3b67142e
6 changed files with 563 additions and 49 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"originHash" : "6e10ca48df0618916616b81379994d6c30d5716d19178aefa050c983082272ce",
|
"originHash" : "ac662b7c30c44a46342d90fd2f894c1a70161e39d60b85657ca39c859e894c44",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "defaults",
|
"identity" : "defaults",
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
|
||||||
if type == .bluetoothAudio {
|
|
||||||
if !hasBatteryLevel {
|
|
||||||
return showBluetoothDeviceNameMarquee ? 118 : 88
|
|
||||||
}
|
|
||||||
|
|
||||||
if useCircularIndicator {
|
let baseTrailingWidth: CGFloat = {
|
||||||
return showBluetoothBatteryPercentageText ? 120 : 96
|
guard type == .bluetoothAudio else { return 100 }
|
||||||
} else {
|
if !hasBatteryLevel {
|
||||||
return showBluetoothBatteryPercentageText ? 136 : 108
|
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 100
|
|
||||||
|
if useCircularIndicator {
|
||||||
|
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 108 : 120) : (enableMinimalisticUI ? 72 : 84)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
updateBatteryStatuses(force: true)
|
if forceCacheRefresh {
|
||||||
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,8 +137,8 @@ final class MediaKeyInterceptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleEvent(cgEvent: CGEvent, type: CGEventType) -> Unmanaged<CGEvent>? {
|
private func handleEvent(cgEvent: CGEvent, type: CGEventType) -> Unmanaged<CGEvent>? {
|
||||||
guard let systemDefinedType = systemDefinedEventType,
|
guard let systemDefinedType = systemDefinedEventType,
|
||||||
type == systemDefinedType,
|
type == systemDefinedType,
|
||||||
let nsEvent = NSEvent(cgEvent: cgEvent),
|
let nsEvent = NSEvent(cgEvent: cgEvent),
|
||||||
nsEvent.subtype.rawValue == 8 else {
|
nsEvent.subtype.rawValue == 8 else {
|
||||||
return Unmanaged.passUnretained(cgEvent)
|
return Unmanaged.passUnretained(cgEvent)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue