mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Compare commits
No commits in common. "3e5f7651dd30bc1523de8d74c62cba2d296f25bf" and "6acb98aadbb7a459e8e792aff10ca93130318bb1" have entirely different histories.
3e5f7651dd
...
6acb98aadb
3 changed files with 27 additions and 141 deletions
|
|
@ -38,9 +38,7 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private let coordinator = DynamicIslandViewCoordinator.shared
|
private let coordinator = DynamicIslandViewCoordinator.shared
|
||||||
private var pollingTimer: DispatchSourceTimer?
|
private var pollingTimer: Timer?
|
||||||
private let pollingQueue = DispatchQueue(label: "com.dynamicisland.bluetooth.polling", qos: .utility)
|
|
||||||
private let postConnectReReadDelays: [TimeInterval] = [0.5, 2.0]
|
|
||||||
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
|
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
|
||||||
private let batteryReader = BluetoothLEBatteryReader()
|
private let batteryReader = BluetoothLEBatteryReader()
|
||||||
private var isLiveBatteryRefreshInFlight = false
|
private var isLiveBatteryRefreshInFlight = false
|
||||||
|
|
@ -119,50 +117,26 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter")
|
print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts polling for device connection changes (fallback mechanism).
|
/// Starts polling for device connection changes (fallback mechanism)
|
||||||
/// Uses DispatchSourceTimer so main-thread back-pressure (SwiftUI updates, animations)
|
|
||||||
/// can't stall the cadence and widen the window where a missed
|
|
||||||
/// IOBluetoothDeviceConnectedNotification goes unnoticed.
|
|
||||||
private func startPollingForChanges() {
|
private func startPollingForChanges() {
|
||||||
print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...")
|
print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...")
|
||||||
|
|
||||||
let timer = DispatchSource.makeTimerSource(queue: pollingQueue)
|
pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||||
timer.schedule(deadline: .now() + 3.0, repeating: 3.0, leeway: .milliseconds(500))
|
self?.checkForDeviceChanges()
|
||||||
timer.setEventHandler { [weak self] in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self?.checkForDeviceChanges()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
timer.resume()
|
|
||||||
pollingTimer = timer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the Bluetooth host-controller power state. Returns `nil` when the
|
|
||||||
/// controller object itself is unavailable — distinct from "powered off" so
|
|
||||||
/// callers can preserve in-memory device state across early-launch or transient
|
|
||||||
/// IOBluetooth registry blips instead of mistakenly clearing connectedDevices.
|
|
||||||
private func bluetoothIsPoweredOn() -> Bool? {
|
|
||||||
guard let controller = IOBluetoothHostController.default() else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return controller.powerState == kBluetoothHCIPowerStateON
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks for device connection/disconnection changes
|
/// Checks for device connection/disconnection changes
|
||||||
private func checkForDeviceChanges() {
|
private func checkForDeviceChanges() {
|
||||||
switch bluetoothIsPoweredOn() {
|
// Check if Bluetooth is powered on
|
||||||
case .some(false):
|
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||||||
|
// Bluetooth is off - clear connected devices if any
|
||||||
if !connectedDevices.isEmpty {
|
if !connectedDevices.isEmpty {
|
||||||
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth powered off - clearing connected devices")
|
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth powered off - clearing connected devices")
|
||||||
connectedDevices.removeAll()
|
connectedDevices.removeAll()
|
||||||
isBluetoothAudioConnected = false
|
isBluetoothAudioConnected = false
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
case .none:
|
|
||||||
// Controller momentarily unavailable; preserve last-known state and retry next tick.
|
|
||||||
return
|
|
||||||
case .some(true):
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||||||
|
|
@ -195,9 +169,10 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
/// Checks for already connected Bluetooth audio devices on init
|
/// Checks for already connected Bluetooth audio devices on init
|
||||||
private func checkInitialDevices() {
|
private func checkInitialDevices() {
|
||||||
print("🎧 [BluetoothAudioManager] Checking for initially connected devices...")
|
print("🎧 [BluetoothAudioManager] Checking for initially connected devices...")
|
||||||
|
|
||||||
guard bluetoothIsPoweredOn() == true else {
|
// Check if Bluetooth is powered on
|
||||||
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth not ready - skipping initial check")
|
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||||||
|
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping initial check")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,8 +222,9 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
|
|
||||||
/// Checks for newly connected devices and displays HUD for new ones
|
/// Checks for newly connected devices and displays HUD for new ones
|
||||||
private func checkForNewlyConnectedDevices() {
|
private func checkForNewlyConnectedDevices() {
|
||||||
guard bluetoothIsPoweredOn() == true else {
|
// Check if Bluetooth is powered on
|
||||||
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth not ready - skipping device check")
|
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||||||
|
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping device check")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,8 +254,7 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
isBluetoothAudioConnected = true
|
isBluetoothAudioConnected = true
|
||||||
|
|
||||||
refreshBatteryLevelsForConnectedDevices()
|
refreshBatteryLevelsForConnectedDevices()
|
||||||
scheduleDeviceMetadataReReads(forAddress: audioDevice.address)
|
|
||||||
|
|
||||||
// Show HUD for new connection
|
// Show HUD for new connection
|
||||||
if let refreshedDevice = connectedDevices.last {
|
if let refreshedDevice = connectedDevices.last {
|
||||||
showDeviceConnectedHUD(refreshedDevice)
|
showDeviceConnectedHUD(refreshedDevice)
|
||||||
|
|
@ -289,54 +264,6 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// IOBluetoothDevice properties (name, vendor/product ID, registry battery)
|
|
||||||
/// are not always populated at the instant IOBluetoothDeviceConnectedNotification
|
|
||||||
/// fires. Re-read the device after a short settle period to catch the
|
|
||||||
/// late-population window without spamming the registry.
|
|
||||||
private func scheduleDeviceMetadataReReads(forAddress address: String) {
|
|
||||||
for delay in postConnectReReadDelays {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
||||||
self?.refreshDeviceMetadata(forAddress: address)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshDeviceMetadata(forAddress address: String) {
|
|
||||||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice],
|
|
||||||
let ioDevice = pairedDevices.first(where: { $0.addressString == address && $0.isConnected() })
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
guard let index = connectedDevices.firstIndex(where: { $0.address == address }) else { return }
|
|
||||||
let existing = connectedDevices[index]
|
|
||||||
|
|
||||||
let updatedName = ioDevice.name ?? existing.name
|
|
||||||
let updatedType = detectDeviceType(from: ioDevice, name: updatedName)
|
|
||||||
let updatedBattery = getBatteryLevel(from: ioDevice) ?? existing.batteryLevel
|
|
||||||
|
|
||||||
let nameChanged = updatedName != existing.name
|
|
||||||
let typeChanged = updatedType != existing.deviceType
|
|
||||||
let batteryChanged = updatedBattery != existing.batteryLevel
|
|
||||||
|
|
||||||
guard nameChanged || typeChanged || batteryChanged else { return }
|
|
||||||
|
|
||||||
let refreshed = BluetoothAudioDevice(
|
|
||||||
id: existing.id,
|
|
||||||
name: updatedName,
|
|
||||||
address: existing.address,
|
|
||||||
batteryLevel: updatedBattery,
|
|
||||||
deviceType: updatedType
|
|
||||||
)
|
|
||||||
connectedDevices[index] = refreshed
|
|
||||||
if lastConnectedDevice?.address == address {
|
|
||||||
lastConnectedDevice = refreshed
|
|
||||||
}
|
|
||||||
print("🎧 [BluetoothAudioManager] 🔁 Refreshed metadata for \(updatedName) (name:\(nameChanged) type:\(typeChanged) battery:\(batteryChanged))")
|
|
||||||
|
|
||||||
if batteryChanged, let battery = updatedBattery {
|
|
||||||
updateActiveBluetoothHUDBattery(with: battery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the list of connected devices (for disconnections)
|
/// Updates the list of connected devices (for disconnections)
|
||||||
private func updateConnectedDevices() {
|
private func updateConnectedDevices() {
|
||||||
|
|
@ -1765,8 +1692,8 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
|
|
||||||
private func cleanup() {
|
private func cleanup() {
|
||||||
print("🎧 [BluetoothAudioManager] Cleaning up observers...")
|
print("🎧 [BluetoothAudioManager] Cleaning up observers...")
|
||||||
|
|
||||||
pollingTimer?.cancel()
|
pollingTimer?.invalidate()
|
||||||
pollingTimer = nil
|
pollingTimer = nil
|
||||||
|
|
||||||
let dnc = DistributedNotificationCenter.default()
|
let dnc = DistributedNotificationCenter.default()
|
||||||
|
|
@ -2083,7 +2010,7 @@ extension BluetoothAudioDevice {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BluetoothAudioDeviceType: Equatable {
|
enum BluetoothAudioDeviceType {
|
||||||
case airpods
|
case airpods
|
||||||
case airpodsGen3
|
case airpodsGen3
|
||||||
case airpodsGen4
|
case airpodsGen4
|
||||||
|
|
|
||||||
|
|
@ -312,31 +312,11 @@ extension LockScreenManager {
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LockSoundPlayer {
|
final class LockSoundPlayer {
|
||||||
static let shared = LockSoundPlayer()
|
static let shared = LockSoundPlayer()
|
||||||
// Same-type throttle defends against repeat same-state notifications
|
private let throttleInterval: TimeInterval = 0.25
|
||||||
// (e.g. distributed + workspace observers both firing screenLocked).
|
|
||||||
private let throttleInterval: TimeInterval = 0.6
|
|
||||||
// Cross-type cooldown defends against the lid close→open scenario where
|
|
||||||
// macOS fires screenIsLocked and screenIsUnlocked within a few hundred ms
|
|
||||||
// of each other — without this, the user hears lock-chime-then-unlock-chime
|
|
||||||
// as a single doubled sound.
|
|
||||||
private let crossTypeCooldown: TimeInterval = 1.2
|
|
||||||
private var players: [SoundType: AVAudioPlayer] = [:]
|
private var players: [SoundType: AVAudioPlayer] = [:]
|
||||||
private var lastPlaybackDates: [SoundType: Date] = [:]
|
private var lastPlaybackDates: [SoundType: Date] = [:]
|
||||||
private var lastPlayedType: SoundType?
|
|
||||||
private var lastAnyPlaybackDate: Date?
|
|
||||||
private var lockSoundsEnabledCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
private init() {
|
private init() {}
|
||||||
// Reset throttle state when the user toggles enableLockSounds off then on,
|
|
||||||
// so the first chime after re-enable isn't suppressed by a stale timestamp.
|
|
||||||
lockSoundsEnabledCancellable = Defaults.publisher(.enableLockSounds)
|
|
||||||
.sink { [weak self] change in
|
|
||||||
guard change.newValue else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.resetThrottleState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func playLockChime() {
|
func playLockChime() {
|
||||||
play(.lock)
|
play(.lock)
|
||||||
|
|
@ -353,31 +333,12 @@ final class LockSoundPlayer {
|
||||||
|
|
||||||
player.currentTime = 0
|
player.currentTime = 0
|
||||||
player.play()
|
player.play()
|
||||||
let now = Date()
|
lastPlaybackDates[type] = Date()
|
||||||
lastPlaybackDates[type] = now
|
|
||||||
lastAnyPlaybackDate = now
|
|
||||||
lastPlayedType = type
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldPlay(_ type: SoundType) -> Bool {
|
private func shouldPlay(_ type: SoundType) -> Bool {
|
||||||
let now = Date()
|
guard let last = lastPlaybackDates[type] else { return true }
|
||||||
if let last = lastPlaybackDates[type],
|
return Date().timeIntervalSince(last) >= throttleInterval
|
||||||
now.timeIntervalSince(last) < throttleInterval {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if let lastAny = lastAnyPlaybackDate,
|
|
||||||
let lastType = lastPlayedType,
|
|
||||||
lastType != type,
|
|
||||||
now.timeIntervalSince(lastAny) < crossTypeCooldown {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetThrottleState() {
|
|
||||||
lastPlaybackDates.removeAll()
|
|
||||||
lastAnyPlaybackDate = nil
|
|
||||||
lastPlayedType = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvePlayer(for type: SoundType) -> AVAudioPlayer? {
|
private func resolvePlayer(for type: SoundType) -> AVAudioPlayer? {
|
||||||
|
|
@ -401,7 +362,7 @@ final class LockSoundPlayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SoundType: String, Equatable {
|
private enum SoundType: String {
|
||||||
case lock
|
case lock
|
||||||
case unlock
|
case unlock
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -463,9 +463,7 @@ class StatsManager: ObservableObject {
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private let minUpdateInterval: TimeInterval = 1.0
|
private let minUpdateInterval: TimeInterval = 1.0
|
||||||
private let maxUpdateInterval: TimeInterval = 60.0
|
private let maxUpdateInterval: TimeInterval = 60.0
|
||||||
// Shorter than the previous 3.0 s: keeps stats "warm" enough for fast open→close→open
|
private let notchCloseStopDelay: TimeInterval = 3.0
|
||||||
// cycles while freeing the main-thread tick budget sooner after a deliberate close.
|
|
||||||
private let notchCloseStopDelay: TimeInterval = 1.0
|
|
||||||
private let tabSwitchStopDelay: TimeInterval = 0.1
|
private let tabSwitchStopDelay: TimeInterval = 0.1
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue