From 2453ca89ffe61732a9786f06371dd3dcb1b83392 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 4 Feb 2026 23:48:48 +0530 Subject: [PATCH] Bluetooth HUD Animations --- .../BluetoothHUDAnimations/airpods.mov | 3 + .../BluetoothHUDAnimations/airpodsGen3.mov | 3 + .../BluetoothHUDAnimations/airpodsGen4.mov | 3 + .../BluetoothHUDAnimations/airpodsMax.mov | 3 + .../BluetoothHUDAnimations/airpodsPro.mov | 3 + .../BluetoothHUDAnimations/airpodsPro3.mov | 3 + .../BluetoothHUDAnimations/beatssolo.mov | 3 + .../BluetoothHUDAnimations/beatsstudio.mov | 3 + .../Live activities/InlineHUD.swift | 73 ++++- .../components/Settings/SettingsView.swift | 3 + .../managers/BluetoothAudioManager.swift | 271 +++++++++++++++++- DynamicIsland/models/Constants.swift | 1 + 12 files changed, 366 insertions(+), 6 deletions(-) create mode 100755 DynamicIsland/BluetoothHUDAnimations/airpods.mov create mode 100755 DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov create mode 100644 DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov create mode 100755 DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov create mode 100755 DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov create mode 100644 DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov create mode 100644 DynamicIsland/BluetoothHUDAnimations/beatssolo.mov create mode 100644 DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov diff --git a/DynamicIsland/BluetoothHUDAnimations/airpods.mov b/DynamicIsland/BluetoothHUDAnimations/airpods.mov new file mode 100755 index 0000000..96e69a1 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpods.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54332f65534c9af667a302e9c2df17e4420bffb94e93cecd0091b11a05e70efc +size 351501 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov new file mode 100755 index 0000000..37b635e --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:329be57b63f437e3329772b688440b825ae4b27ece16ac33c483d43b3725d0cf +size 391452 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov new file mode 100644 index 0000000..089fda0 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04b9bce0be1bb910c10d7466b795e0909cbf70d6b62e5f227124d7c67bc9b53a +size 352641 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov new file mode 100755 index 0000000..1cab6ca --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b587c84649e0f3986d59b46fab527a15b2c8146eb9c57a523a2e899afe41ac20 +size 476065 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov new file mode 100755 index 0000000..ee76359 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2be0bc5150dd21d85c9d64a18ec5a782279e30980996e726a6c8d3a8cecb775 +size 463126 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov new file mode 100644 index 0000000..e03ff48 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df3275c52a9599dbd5768aba279c8904037925bbed6a02a2166a0a01fb730875 +size 398759 diff --git a/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov b/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov new file mode 100644 index 0000000..5e1cbb7 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c59f43e4f3446a163dde871035971eb1482475051dbc67e5f6a771b5fb04f13 +size 393932 diff --git a/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov b/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov new file mode 100644 index 0000000..2a772ba --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:835ba4373e8f3dd85e2ff77655eb9464ed07a58687b3b79b51a4535c5febd26a +size 443917 diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index e319b29..4f55720 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -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) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index bd1a815..19defb3 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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: { diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index a7d3d30..869abf6 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -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 diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 246b05b..2ba4464 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -838,6 +838,7 @@ extension Defaults.Keys { static let useCircularBluetoothBatteryIndicator = Key("useCircularBluetoothBatteryIndicator", default: true) static let showBluetoothBatteryPercentageText = Key("showBluetoothBatteryPercentageText", default: false) static let showBluetoothDeviceNameMarquee = Key("showBluetoothDeviceNameMarquee", default: false) + static let useBluetoothHUD3DIcon = Key("useBluetoothHUD3DIcon", default: true) // MARK: Stats Feature static let enableStatsFeature = Key("enableStatsFeature", default: false)