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 SwiftUI
import AppKit
import AVFoundation
import Defaults 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 { struct InlineHUD: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@Binding var type: SneakContentType @Binding var type: SneakContentType
@ -39,6 +96,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(.useBluetoothHUD3DIcon) var useBluetoothHUD3DIcon
@Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.enableMinimalisticUI) var enableMinimalisticUI
@Default(.showCapsLockLabel) var showCapsLockLabel @Default(.showCapsLockLabel) var showCapsLockLabel
@Default(.capsLockIndicatorTintMode) var capsLockTintMode @Default(.capsLockIndicatorTintMode) var capsLockTintMode
@ -173,10 +231,17 @@ struct InlineHUD: View {
.contentTransition(.interpolate) .contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center) .frame(width: 20, height: 15, alignment: .center)
case .bluetoothAudio: case .bluetoothAudio:
Image(systemName: icon.isEmpty ? "bluetooth" : icon) if useBluetoothHUD3DIcon,
.symbolRenderingMode(.hierarchical) let deviceType = bluetoothManager.lastConnectedDevice?.deviceType,
.contentTransition(.interpolate) let url = Bundle.main.url(forResource: deviceType.inlineHUDAnimationBaseName, withExtension: "mov") {
.frame(width: 20, height: 15, alignment: .center) 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: case .capsLock:
Image(systemName: "capslock.fill") Image(systemName: "capslock.fill")
.symbolRenderingMode(.hierarchical) .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: "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: "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: "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: .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: "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")), 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")) .settingsHighlight(id: highlightID("Show battery percentage text in HUD"))
Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee) Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee)
.settingsHighlight(id: highlightID("Scroll device name in HUD")) .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: { } header: {
Text("Bluetooth Audio Devices") Text("Bluetooth Audio Devices")
} footer: { } footer: {

View file

@ -43,6 +43,25 @@ class BluetoothAudioManager: ObservableObject {
private let batteryReader = BluetoothLEBatteryReader() private let batteryReader = BluetoothLEBatteryReader()
private var isLiveBatteryRefreshInFlight = false 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] = [:] @Published private(set) var batteryStatus: [String: String] = [:]
private var batteryStatusByAddress: [String: Int] = [:] private var batteryStatusByAddress: [String: Int] = [:]
@ -396,24 +415,247 @@ class BluetoothAudioManager: ObservableObject {
logMissingBatteryInfo(for: device) logMissingBatteryInfo(for: device)
return nil 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 /// Detects the type of audio device based on name and properties
private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType { private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType {
let lowercaseName = name.lowercased() let lowercaseName = name.lowercased()
if let pidBasedType = airPodsTypeFromPID(device) {
return pidBasedType
}
// Check for specific AirPods models // Check for specific AirPods models
if lowercaseName.contains("airpods") { if lowercaseName.contains("airpods") {
if lowercaseName.contains("max") { if lowercaseName.contains("max") {
return .airpodsMax return .airpodsMax
} else if lowercaseName.contains("pro") { } else if lowercaseName.contains("pro") {
if lowercaseName.contains("3") || lowercaseName.contains("gen 3") || lowercaseName.contains("gen3") {
return .airpodsPro3
}
return .airpodsPro 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 return .airpods
} }
// Check for other brands // Check for other brands
if lowercaseName.contains("beats") { 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") { } else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") {
return .speaker return .speaker
} else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") || } else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") ||
@ -1770,9 +2012,14 @@ extension BluetoothAudioDevice {
enum BluetoothAudioDeviceType { enum BluetoothAudioDeviceType {
case airpods case airpods
case airpodsGen3
case airpodsGen4
case airpodsPro case airpodsPro
case airpodsPro3
case airpodsMax case airpodsMax
case beats case beats
case beatsstudio
case beatssolo
case headphones case headphones
case speaker case speaker
case generic case generic
@ -1781,12 +2028,22 @@ enum BluetoothAudioDeviceType {
switch self { switch self {
case .airpods: case .airpods:
return "airpods" return "airpods"
case .airpodsGen3:
return "airpods.gen3"
case .airpodsGen4:
return "airpods.gen4"
case .airpodsPro: case .airpodsPro:
return "airpodspro" return "airpods.pro"
case .airpodsPro3:
return "airpods.pro"
case .airpodsMax: case .airpodsMax:
return "airpodsmax" return "airpodsmax"
case .beats: case .beats:
return "beats.headphones" return "beats.headphones"
case .beatsstudio:
return "beats.headphones"
case .beatssolo:
return "beats.headphones"
case .headphones: case .headphones:
return "headphones" return "headphones"
case .speaker: case .speaker:
@ -1799,14 +2056,24 @@ enum BluetoothAudioDeviceType {
var displayName: String { var displayName: String {
switch self { switch self {
case .airpods: return "AirPods" case .airpods: return "AirPods"
case .airpodsGen3: return "AirPods (Gen 3)"
case .airpodsGen4: return "AirPods (Gen 4)"
case .airpodsPro: return "AirPods Pro" case .airpodsPro: return "AirPods Pro"
case .airpodsPro3: return "AirPods Pro 3"
case .airpodsMax: return "AirPods Max" case .airpodsMax: return "AirPods Max"
case .beats: return "Beats" case .beats: return "Beats"
case .beatsstudio: return "Beats Studio"
case .beatssolo: return "Beats Solo"
case .headphones: return "Headphones" case .headphones: return "Headphones"
case .speaker: return "Speaker" case .speaker: return "Speaker"
case .generic: return "Bluetooth Device" 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 // MARK: - Notification Name Constants

View file

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