BugFix: Out of bounds of headphone hud

This commit is contained in:
Hariharan Mudaliar 2025-11-06 23:23:25 +05:30
commit 2b6faff96d
3 changed files with 499 additions and 32 deletions

View file

@ -24,6 +24,7 @@ struct InlineHUD: View {
@Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
@Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
@Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
@Default(.enableMinimalisticUI) var enableMinimalisticUI
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
@State private var displayName: String = ""
@ -33,35 +34,54 @@ struct InlineHUD: View {
let hasBatteryLevel = value > 0
let baseInfoWidth: CGFloat = {
if type == .bluetoothAudio {
return showBluetoothDeviceNameMarquee ? 140 : 88
guard type == .bluetoothAudio else { return 100 }
if showBluetoothDeviceNameMarquee {
return enableMinimalisticUI ? 128 : 140
}
return 100
return enableMinimalisticUI ? 76 : 88
}()
let infoWidth: CGFloat = {
var width = baseInfoWidth + gestureProgress / 2
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 ? 70 : 82
}()
return max(width, minimum)
}()
let baseTrailingWidth: CGFloat = {
if type == .bluetoothAudio {
if !hasBatteryLevel {
return showBluetoothDeviceNameMarquee ? 118 : 88
}
if useCircularIndicator {
return showBluetoothBatteryPercentageText ? 120 : 96
} else {
return showBluetoothBatteryPercentageText ? 136 : 108
}
let baseTrailingWidth: CGFloat = {
guard type == .bluetoothAudio else { return 100 }
if !hasBatteryLevel {
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88)
}
return 100
if useCircularIndicator {
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 108 : 120) : (enableMinimalisticUI ? 84 : 96)
}
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 118 : 136) : (enableMinimalisticUI ? 92 : 108)
}()
let trailingWidth: CGFloat = {
var width = baseTrailingWidth + gestureProgress / 2
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 ? 70 : 92)
}
if useCircularIndicator {
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 92 : 110) : (enableMinimalisticUI ? 68 : 80)
}
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 104 : 120) : (enableMinimalisticUI ? 82 : 98)
}()
return max(width, minimum)
}()
@ -161,7 +181,14 @@ struct InlineHUD: View {
.contentTransition(.interpolate)
} else if (type == .bluetoothAudio) {
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 {
CircularBatteryIndicator(
value: value,
@ -221,7 +248,7 @@ struct InlineHUD: View {
.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(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)

View file

@ -13,6 +13,7 @@ import Defaults
import SwiftUI
import IOBluetooth
import IOKit
import CoreBluetooth
/// Manages detection and monitoring of Bluetooth audio device connections
class BluetoothAudioManager: ObservableObject {
@ -29,6 +30,8 @@ class BluetoothAudioManager: ObservableObject {
private let coordinator = DynamicIslandViewCoordinator.shared
private var pollingTimer: Timer?
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
private let batteryReader = BluetoothLEBatteryReader()
private var isLiveBatteryRefreshInFlight = false
@Published private(set) var batteryStatus: [String: String] = [:]
@ -411,17 +414,24 @@ class BluetoothAudioManager: ObservableObject {
}
}
private func refreshBatteryLevelsForConnectedDevices() {
updateBatteryStatuses(force: true)
private func refreshBatteryLevelsForConnectedDevices(forceCacheRefresh: Bool = true) {
if forceCacheRefresh {
updateBatteryStatuses(force: true)
}
applyConnectedDeviceBatteryLevels()
triggerLiveBatteryRefreshIfNeeded()
}
private func applyConnectedDeviceBatteryLevels() {
guard !connectedDevices.isEmpty else {
lastConnectedDevice = nil
return
}
var updatedDevices: [BluetoothAudioDevice] = []
for device in connectedDevices {
let refreshedLevel =
batteryLevelFromRegistry(forAddress: device.address)
?? batteryLevelFromRegistry(forName: device.name)
?? batteryLevelFromDefaults(forAddress: device.address)
?? batteryLevelFromDefaults(forName: device.name)
?? device.batteryLevel
let refreshedLevel = bestBatteryLevel(for: device)
let updatedDevice = device.withBatteryLevel(refreshedLevel)
updatedDevices.append(updatedDevice)
@ -433,11 +443,189 @@ class BluetoothAudioManager: ObservableObject {
}
connectedDevices = updatedDevices
if let last = connectedDevices.last {
if let last = updatedDevices.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? {
guard let address, !address.isEmpty 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")
// Convert battery percentage to 0.0-1.0 value
let reportedBattery = device.batteryLevel
?? batteryLevelFromRegistry(forAddress: device.address)
?? batteryLevelFromRegistry(forName: device.name)
?? batteryLevelFromDefaults(forAddress: device.address)
?? batteryLevelFromDefaults(forName: device.name)
let reportedBattery = bestBatteryLevel(for: device)
let batteryValue: CGFloat = if let battery = reportedBattery {
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
struct BluetoothAudioDevice: Identifiable {