Bluetooth HUD Animations

This commit is contained in:
Hariharan Mudaliar 2026-02-04 23:48:48 +05:30
commit 2453ca89ff
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
12 changed files with 366 additions and 6 deletions

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54332f65534c9af667a302e9c2df17e4420bffb94e93cecd0091b11a05e70efc
size 351501

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:329be57b63f437e3329772b688440b825ae4b27ece16ac33c483d43b3725d0cf
size 391452

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04b9bce0be1bb910c10d7466b795e0909cbf70d6b62e5f227124d7c67bc9b53a
size 352641

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b587c84649e0f3986d59b46fab527a15b2c8146eb9c57a523a2e899afe41ac20
size 476065

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2be0bc5150dd21d85c9d64a18ec5a782279e30980996e726a6c8d3a8cecb775
size 463126

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:df3275c52a9599dbd5768aba279c8904037925bbed6a02a2166a0a01fb730875
size 398759

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c59f43e4f3446a163dde871035971eb1482475051dbc67e5f6a771b5fb04f13
size 393932

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:835ba4373e8f3dd85e2ff77655eb9464ed07a58687b3b79b51a4535c5febd26a
size 443917

View file

@ -21,8 +21,65 @@
*/
import SwiftUI
import AppKit
import AVFoundation
import Defaults
// MARK: - Inline HUD looping .mov icon
private final class LoopingPlayerController {
let player: AVQueuePlayer
private var looper: AVPlayerLooper?
init(url: URL) {
let item = AVPlayerItem(url: url)
self.player = AVQueuePlayer()
self.player.isMuted = true
self.player.actionAtItemEnd = .none
self.looper = AVPlayerLooper(player: self.player, templateItem: item)
self.player.play()
}
deinit {
player.pause()
looper = nil
}
}
private struct LoopingVideoIcon: NSViewRepresentable {
let url: URL
let size: CGSize
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: NSRect(origin: .zero, size: size))
view.wantsLayer = true
let layer = AVPlayerLayer()
layer.videoGravity = .resizeAspect
layer.frame = view.bounds
view.layer?.addSublayer(layer)
context.coordinator.attach(layer: layer, url: url)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// No-op; the animation loops via AVPlayerLooper.
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator {
private var controller: LoopingPlayerController?
func attach(layer: AVPlayerLayer, url: URL) {
controller = LoopingPlayerController(url: url)
layer.player = controller?.player
}
}
}
struct InlineHUD: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@Binding var type: SneakContentType
@ -39,6 +96,7 @@ struct InlineHUD: View {
@Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
@Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
@Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
@Default(.useBluetoothHUD3DIcon) var useBluetoothHUD3DIcon
@Default(.enableMinimalisticUI) var enableMinimalisticUI
@Default(.showCapsLockLabel) var showCapsLockLabel
@Default(.capsLockIndicatorTintMode) var capsLockTintMode
@ -173,10 +231,17 @@ struct InlineHUD: View {
.contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center)
case .bluetoothAudio:
Image(systemName: icon.isEmpty ? "bluetooth" : icon)
.symbolRenderingMode(.hierarchical)
.contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center)
if useBluetoothHUD3DIcon,
let deviceType = bluetoothManager.lastConnectedDevice?.deviceType,
let url = Bundle.main.url(forResource: deviceType.inlineHUDAnimationBaseName, withExtension: "mov") {
LoopingVideoIcon(url: url, size: CGSize(width: 20, height: 20))
.frame(width: 20, height: 20, alignment: .center)
} else {
Image(systemName: icon.isEmpty ? "bluetooth" : icon)
.symbolRenderingMode(.hierarchical)
.contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center)
}
case .capsLock:
Image(systemName: "capslock.fill")
.symbolRenderingMode(.hierarchical)

View file

@ -631,6 +631,7 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .devices, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.devices.highlightID(for: "Use circular battery indicator")),
SettingsSearchEntry(tab: .devices, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.devices.highlightID(for: "Show battery percentage text in HUD")),
SettingsSearchEntry(tab: .devices, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.devices.highlightID(for: "Scroll device name in HUD")),
SettingsSearchEntry(tab: .devices, title: "Use 3D Bluetooth HUD icon", keywords: ["bluetooth", "3d", "animation", "mov"], highlightID: SettingsTab.devices.highlightID(for: "Use 3D Bluetooth HUD icon")),
SettingsSearchEntry(tab: .devices, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.devices.highlightID(for: "Color-coded battery display")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded volume display")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Smooth color transitions")),
@ -1783,6 +1784,8 @@ private struct DevicesSettingsView: View {
.settingsHighlight(id: highlightID("Show battery percentage text in HUD"))
Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee)
.settingsHighlight(id: highlightID("Scroll device name in HUD"))
Defaults.Toggle("Use 3D Bluetooth HUD icon", key: .useBluetoothHUD3DIcon)
.settingsHighlight(id: highlightID("Use 3D Bluetooth HUD icon"))
} header: {
Text("Bluetooth Audio Devices")
} footer: {

View file

@ -43,6 +43,25 @@ class BluetoothAudioManager: ObservableObject {
private let batteryReader = BluetoothLEBatteryReader()
private var isLiveBatteryRefreshInFlight = false
private let appleVendorID: UInt16 = 0x05AC
private let devicePIDMap: [UInt16: BluetoothAudioDeviceType] = [
0x2002: .airpods, // Gen 1 AirPods
0x200F: .airpods, // Gen 2 AirPods
0x2013: .airpodsGen3, // Gen 3 AirPods
0x2019: .airpodsGen4, // Gen 4 AirPods
0x201B: .airpodsGen4, // Gen 4 AirPods ANC
0x200A: .airpodsMax, // AirPods Max Lightning
0x201F: .airpodsMax, // AirPods Max USB-C
0x200E: .airpodsPro, // AirPods Pro Gen 1
0x2014: .airpodsPro, // AirPods Pro Gen 2 Lightning
0x2024: .airpodsPro, // AirPods Pro Gen 2 USB-C
0x2027: .airpodsPro3, // AirPods Pro Gen 3
0x2017: .beatsstudio, // Beats Studio Pro
0x2009: .beatsstudio, // Beats Studio 3
0x2006: .beatssolo, // Beats Solo 3
0x200C: .beatssolo // Beats Solo Pro
]
@Published private(set) var batteryStatus: [String: String] = [:]
private var batteryStatusByAddress: [String: Int] = [:]
@ -396,24 +415,247 @@ class BluetoothAudioManager: ObservableObject {
logMissingBatteryInfo(for: device)
return nil
}
// MARK: - PID-based device detection
/// Extract a UInt16 from common payload formats (Int/NSNumber/String including hex like "0x201B").
private func extractUInt16(from payload: [String: Any], keys: [String]) -> UInt16? {
for key in keys {
guard let raw = payload[key] else { continue }
if let number = raw as? NSNumber {
return UInt16(truncatingIfNeeded: number.uint16Value)
}
if let intValue = raw as? Int {
return UInt16(truncatingIfNeeded: intValue)
}
if let string = raw as? String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasPrefix("0x") {
let hex = trimmed.dropFirst(2)
if let value = UInt16(hex, radix: 16) { return value }
} else if let value = UInt16(trimmed, radix: 10) {
return value
}
}
}
return nil
}
/// Recursively search nested dictionaries/arrays for the first UInt16 value whose key matches `predicate`.
private func deepSearchUInt16(in value: Any, predicate: (String) -> Bool) -> UInt16? {
if let dict = value as? [String: Any] {
for (key, entry) in dict {
if predicate(key) {
if let found = extractUInt16(from: dict, keys: [key]) {
return found
}
if let number = entry as? NSNumber { return UInt16(truncatingIfNeeded: number.uint16Value) }
if let intValue = entry as? Int { return UInt16(truncatingIfNeeded: intValue) }
if let string = entry as? String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasPrefix("0x"), let value = UInt16(trimmed.dropFirst(2), radix: 16) { return value }
if let value = UInt16(trimmed, radix: 10) { return value }
}
}
}
for entry in dict.values {
if let found = deepSearchUInt16(in: entry, predicate: predicate) { return found }
}
return nil
}
if let array = value as? [Any] {
for entry in array {
if let found = deepSearchUInt16(in: entry, predicate: predicate) { return found }
}
return nil
}
return nil
}
/// Fallback: attempt to get VendorID/ProductID from system_profiler SPBluetoothDataType JSON.
private func vendorProductIDsFromSystemProfiler(forNormalizedAddress target: String) -> (vendor: UInt16, product: UInt16)? {
guard !target.isEmpty else { return nil }
guard let root = systemProfilerBluetoothDictionary() else { return nil }
guard let deviceConnected = root["device_connected"] as? [Any] else { return nil }
func pidFromPayload(_ payload: [String: Any]) -> UInt16? {
if let raw = payload["device_productID"] as? String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasPrefix("0x"), let value = UInt16(trimmed.dropFirst(2), radix: 16) { return value }
if let value = UInt16(trimmed, radix: 16) { return value }
}
let productKeys = ["device_productID", "ProductID", "product_id", "productID", "DeviceProductID", "ProductId", "Product ID"]
return extractUInt16(from: payload, keys: productKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") }
}
func vidFromPayload(_ payload: [String: Any]) -> UInt16? {
let vendorKeys = ["device_vendorID", "VendorID", "vendor_id", "vendorID", "DeviceVendorID", "VendorId", "Vendor ID"]
return extractUInt16(from: payload, keys: vendorKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") }
}
for item in deviceConnected {
guard let dict = item as? [String: Any],
let nameKey = dict.keys.first,
let infoAny = dict[nameKey],
let payload = infoAny as? [String: Any] else {
continue
}
if let address = payload["device_address"] as? String {
if normalizeBluetoothIdentifier(address) != target { continue }
} else {
let candidates = profilerAddressCandidates(from: payload).map(normalizeBluetoothIdentifier)
if !candidates.contains(target) { continue }
}
if let pid = pidFromPayload(payload) {
if let vid = vidFromPayload(payload) {
return (vendor: vid, product: pid)
}
if devicePIDMap[pid] != nil {
return (vendor: appleVendorID, product: pid)
}
}
}
return nil
}
/// Attempts to find VendorID/ProductID for a device using Bluetooth caches.
private func vendorProductIDs(for device: IOBluetoothDevice) -> (vendor: UInt16, product: UInt16)? {
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
return nil
}
let target = normalizeBluetoothIdentifier(device.addressString ?? "")
guard !target.isEmpty else { return nil }
let vendorKeys = [
"VendorID", "vendor_id", "vendorID",
"device_vendorID", "DeviceVendorID", "device_vendor_id",
"device_vendorId", "DeviceVendorId",
"VendorId", "Vendor ID",
"VendorIDSource", "VendorIDSourceLocal", "VendorIDSourceRemote"
]
let productKeys = [
"ProductID", "product_id", "productID",
"device_productID", "DeviceProductID", "device_product_id",
"device_productId", "DeviceProductId",
"ProductId", "Product ID",
"ProductIDSource", "ProductIDSourceLocal", "ProductIDSourceRemote"
]
for (key, value) in deviceCache {
guard let payload = value as? [String: Any] else { continue }
if matchesBluetoothIdentifier(target, key: key, payload: payload) {
let vendor = extractUInt16(from: payload, keys: vendorKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") }
let product = extractUInt16(from: payload, keys: productKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") }
if let product {
if let vendor {
return (vendor: vendor, product: product)
}
if devicePIDMap[product] != nil {
return (vendor: appleVendorID, product: product)
}
}
}
}
if let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] {
for payload in coreCache.values {
if let addressValue = payload["DeviceAddress"]
?? payload["Address"]
?? payload["BD_ADDR"]
?? payload["device_address"],
let address = normalizeBluetoothIdentifier(from: addressValue),
address == target {
let vendor = extractUInt16(from: payload, keys: vendorKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") }
let product = extractUInt16(from: payload, keys: productKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") }
if let product {
if let vendor {
return (vendor: vendor, product: product)
}
if devicePIDMap[product] != nil {
return (vendor: appleVendorID, product: product)
}
}
}
}
}
if let fromProfiler = vendorProductIDsFromSystemProfiler(forNormalizedAddress: target) {
return fromProfiler
}
return nil
}
/// Attempts to detect AirPods/Beats type using vendor/product IDs.
private func airPodsTypeFromPID(_ device: IOBluetoothDevice) -> BluetoothAudioDeviceType? {
if let ids = vendorProductIDs(for: device) {
return devicePIDMap[ids.product]
}
return nil
}
/// Detects the type of audio device based on name and properties
private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType {
let lowercaseName = name.lowercased()
if let pidBasedType = airPodsTypeFromPID(device) {
return pidBasedType
}
// Check for specific AirPods models
if lowercaseName.contains("airpods") {
if lowercaseName.contains("max") {
return .airpodsMax
} else if lowercaseName.contains("pro") {
if lowercaseName.contains("3") || lowercaseName.contains("gen 3") || lowercaseName.contains("gen3") {
return .airpodsPro3
}
return .airpodsPro
} else if lowercaseName.contains("gen 4")
|| lowercaseName.contains("gen4")
|| lowercaseName.contains("4th")
|| lowercaseName.contains("airpods 4")
|| lowercaseName.contains("airpods4") {
return .airpodsGen4
} else if lowercaseName.contains("gen 3")
|| lowercaseName.contains("gen3")
|| lowercaseName.contains("3rd")
|| lowercaseName.contains("third")
|| lowercaseName.contains("airpods 3")
|| lowercaseName.contains("airpods3") {
return .airpodsGen3
}
return .airpods
}
// Check for other brands
if lowercaseName.contains("beats") {
return .beats
if lowercaseName.contains("studio") {
return .beatsstudio
}
if lowercaseName.contains("solo") {
return .beatssolo
}
return .beatssolo
} else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") {
return .speaker
} else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") ||
@ -1770,9 +2012,14 @@ extension BluetoothAudioDevice {
enum BluetoothAudioDeviceType {
case airpods
case airpodsGen3
case airpodsGen4
case airpodsPro
case airpodsPro3
case airpodsMax
case beats
case beatsstudio
case beatssolo
case headphones
case speaker
case generic
@ -1781,12 +2028,22 @@ enum BluetoothAudioDeviceType {
switch self {
case .airpods:
return "airpods"
case .airpodsGen3:
return "airpods.gen3"
case .airpodsGen4:
return "airpods.gen4"
case .airpodsPro:
return "airpodspro"
return "airpods.pro"
case .airpodsPro3:
return "airpods.pro"
case .airpodsMax:
return "airpodsmax"
case .beats:
return "beats.headphones"
case .beatsstudio:
return "beats.headphones"
case .beatssolo:
return "beats.headphones"
case .headphones:
return "headphones"
case .speaker:
@ -1799,14 +2056,24 @@ enum BluetoothAudioDeviceType {
var displayName: String {
switch self {
case .airpods: return "AirPods"
case .airpodsGen3: return "AirPods (Gen 3)"
case .airpodsGen4: return "AirPods (Gen 4)"
case .airpodsPro: return "AirPods Pro"
case .airpodsPro3: return "AirPods Pro 3"
case .airpodsMax: return "AirPods Max"
case .beats: return "Beats"
case .beatsstudio: return "Beats Studio"
case .beatssolo: return "Beats Solo"
case .headphones: return "Headphones"
case .speaker: return "Speaker"
case .generic: return "Bluetooth Device"
}
}
/// Inline HUD only: base filename (no extension) for a looping .mov animation.
var inlineHUDAnimationBaseName: String {
String(describing: self)
}
}
// MARK: - Notification Name Constants

View file

@ -838,6 +838,7 @@ extension Defaults.Keys {
static let useCircularBluetoothBatteryIndicator = Key<Bool>("useCircularBluetoothBatteryIndicator", default: true)
static let showBluetoothBatteryPercentageText = Key<Bool>("showBluetoothBatteryPercentageText", default: false)
static let showBluetoothDeviceNameMarquee = Key<Bool>("showBluetoothDeviceNameMarquee", default: false)
static let useBluetoothHUD3DIcon = Key<Bool>("useBluetoothHUD3DIcon", default: true)
// MARK: Stats Feature
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)