VolumeButtonHandler.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. // LoopFollow
  2. // VolumeButtonHandler.swift
  3. import AVFoundation
  4. import Combine
  5. import Foundation
  6. import MediaPlayer
  7. import UIKit
  8. class VolumeButtonHandler: NSObject {
  9. static let shared = VolumeButtonHandler()
  10. // Volume button snoozer activation delay in seconds
  11. private let volumeButtonActivationDelay: TimeInterval = 0.9
  12. // Volume button detection parameters
  13. private let volumeButtonPressThreshold: Float = 0.02
  14. private let volumeButtonPressTimeWindow: TimeInterval = 0.3
  15. private let volumeButtonCooldown: TimeInterval = 0.5
  16. // KVO observer for system volume
  17. private var volumeObserver: NSKeyValueObservation?
  18. private var lastVolume: Float = 0.0
  19. private var isMonitoring = false
  20. private var alarmStartTime: Date?
  21. private var lastVolumeButtonPressTime: Date?
  22. // Button press detection
  23. private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = []
  24. private var lastSignificantVolumeChange: Date?
  25. private var volumeChangePattern: [TimeInterval] = []
  26. // Remote command center for handling bluetooth/CarPlay buttons
  27. private var remoteCommandsEnabled = false
  28. private var cancellables = Set<AnyCancellable>()
  29. override private init() {
  30. super.init()
  31. Observable.shared.alarmSoundPlaying.$value
  32. .removeDuplicates()
  33. .sink { [weak self] alarmSoundPlaying in
  34. guard let self = self else { return }
  35. if alarmSoundPlaying {
  36. self.alarmStarted()
  37. } else {
  38. self.alarmStopped()
  39. }
  40. }
  41. .store(in: &cancellables)
  42. }
  43. private func recordVolumeChange(currentVolume: Float, timestamp: Date) {
  44. recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp))
  45. let cutoffTime = timestamp.timeIntervalSinceReferenceDate - volumeButtonPressTimeWindow
  46. recentVolumeChanges = recentVolumeChanges.filter { $0.timestamp.timeIntervalSinceReferenceDate > cutoffTime }
  47. if let lastChange = lastSignificantVolumeChange {
  48. let timeSinceLastChange = timestamp.timeIntervalSince(lastChange)
  49. volumeChangePattern.append(timeSinceLastChange)
  50. if volumeChangePattern.count > 5 {
  51. volumeChangePattern.removeFirst()
  52. }
  53. }
  54. lastSignificantVolumeChange = timestamp
  55. }
  56. private func isLikelyVolumeButtonPress(volumeDifference: Float, timestamp: Date) -> Bool {
  57. let isReasonableChange = volumeDifference >= 0.03 && volumeDifference <= 0.12
  58. let isDiscreteChange = recentVolumeChanges.count <= 2
  59. let hasConsistentTiming = volumeChangePattern.isEmpty || volumeChangePattern.last! >= 0.15
  60. let isNotRapidSequence = recentVolumeChanges.count < 3 ||
  61. (recentVolumeChanges.count >= 3 &&
  62. recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }.enumerated().dropFirst().allSatisfy { index, timestamp in
  63. let previousTimestamp = recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }[index - 1]
  64. return timestamp - previousTimestamp > 0.08
  65. })
  66. return isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence
  67. }
  68. private func snoozeActiveAlarm() {
  69. LogManager.shared.log(category: .volumeButtonSnooze, message: "Snoozing alarm")
  70. lastVolumeButtonPressTime = Date()
  71. AlarmManager.shared.performSnooze()
  72. let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
  73. impactFeedback.impactOccurred()
  74. }
  75. private func alarmStarted() {
  76. guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return }
  77. LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected, setting up volume observer.")
  78. alarmStartTime = Date()
  79. recentVolumeChanges.removeAll()
  80. lastSignificantVolumeChange = nil
  81. volumeChangePattern.removeAll()
  82. startMonitoring()
  83. }
  84. private func alarmStopped() {
  85. guard isMonitoring else { return }
  86. alarmStartTime = nil
  87. stopMonitoring()
  88. recentVolumeChanges.removeAll()
  89. lastSignificantVolumeChange = nil
  90. volumeChangePattern.removeAll()
  91. }
  92. private func setupRemoteCommandCenter() {
  93. guard !remoteCommandsEnabled else { return }
  94. let commandCenter = MPRemoteCommandCenter.shared()
  95. // Log current audio route to help with debugging
  96. let currentRoute = AVAudioSession.sharedInstance().currentRoute
  97. LogManager.shared.log(category: .volumeButtonSnooze, message: "Audio route: \(currentRoute.outputs.map { $0.portName }.joined(separator: ", "))")
  98. // Enable pause command - handles play/pause button on bluetooth devices and CarPlay
  99. commandCenter.pauseCommand.isEnabled = true
  100. commandCenter.pauseCommand.addTarget { [weak self] _ in
  101. guard let self = self else { return .commandFailed }
  102. LogManager.shared.log(category: .volumeButtonSnooze, message: "Pause command received from remote")
  103. // Check if alarm is currently active and activation delay has passed
  104. if let alarmStartTime = self.alarmStartTime {
  105. let timeSinceAlarmStart = Date().timeIntervalSince(alarmStartTime)
  106. if timeSinceAlarmStart > self.volumeButtonActivationDelay {
  107. // Check cooldown
  108. if let lastPress = self.lastVolumeButtonPressTime {
  109. let timeSinceLastPress = Date().timeIntervalSince(lastPress)
  110. if timeSinceLastPress < self.volumeButtonCooldown {
  111. return .success
  112. }
  113. }
  114. LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command pause received - snoozing alarm")
  115. self.snoozeActiveAlarm()
  116. return .success
  117. }
  118. }
  119. return .commandFailed
  120. }
  121. // Enable play command as well for symmetry
  122. commandCenter.playCommand.isEnabled = true
  123. commandCenter.playCommand.addTarget { [weak self] _ in
  124. guard let self = self else { return .commandFailed }
  125. LogManager.shared.log(category: .volumeButtonSnooze, message: "Play command received from remote")
  126. if let alarmStartTime = self.alarmStartTime {
  127. let timeSinceAlarmStart = Date().timeIntervalSince(alarmStartTime)
  128. if timeSinceAlarmStart > self.volumeButtonActivationDelay {
  129. if let lastPress = self.lastVolumeButtonPressTime {
  130. let timeSinceLastPress = Date().timeIntervalSince(lastPress)
  131. if timeSinceLastPress < self.volumeButtonCooldown {
  132. return .success
  133. }
  134. }
  135. LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command play received - snoozing alarm")
  136. self.snoozeActiveAlarm()
  137. return .success
  138. }
  139. }
  140. return .commandFailed
  141. }
  142. // Enable toggle play/pause command - common on many bluetooth devices
  143. commandCenter.togglePlayPauseCommand.isEnabled = true
  144. commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
  145. guard let self = self else { return .commandFailed }
  146. LogManager.shared.log(category: .volumeButtonSnooze, message: "Toggle play/pause command received from remote")
  147. if let alarmStartTime = self.alarmStartTime {
  148. let timeSinceAlarmStart = Date().timeIntervalSince(alarmStartTime)
  149. if timeSinceAlarmStart > self.volumeButtonActivationDelay {
  150. if let lastPress = self.lastVolumeButtonPressTime {
  151. let timeSinceLastPress = Date().timeIntervalSince(lastPress)
  152. if timeSinceLastPress < self.volumeButtonCooldown {
  153. return .success
  154. }
  155. }
  156. LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command toggle play/pause received - snoozing alarm")
  157. self.snoozeActiveAlarm()
  158. return .success
  159. }
  160. }
  161. return .commandFailed
  162. }
  163. remoteCommandsEnabled = true
  164. LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command center configured for bluetooth/CarPlay button handling")
  165. }
  166. private func disableRemoteCommandCenter() {
  167. guard remoteCommandsEnabled else { return }
  168. let commandCenter = MPRemoteCommandCenter.shared()
  169. commandCenter.pauseCommand.isEnabled = false
  170. commandCenter.playCommand.isEnabled = false
  171. commandCenter.togglePlayPauseCommand.isEnabled = false
  172. // Remove all targets
  173. commandCenter.pauseCommand.removeTarget(nil)
  174. commandCenter.playCommand.removeTarget(nil)
  175. commandCenter.togglePlayPauseCommand.removeTarget(nil)
  176. remoteCommandsEnabled = false
  177. LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command center disabled")
  178. }
  179. func startMonitoring() {
  180. guard !isMonitoring else { return }
  181. isMonitoring = true
  182. // Setup remote command center for bluetooth/CarPlay button handling
  183. setupRemoteCommandCenter()
  184. volumeObserver = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] session, _ in
  185. guard let self = self, let alarmStartTime = self.alarmStartTime else { return }
  186. let currentVolume = session.outputVolume
  187. let now = Date()
  188. // On the first observation, capture the initial volume when the audio session
  189. // becomes active. This solves the race condition. We then return to avoid
  190. // treating this initial setup as a user-initiated button press.
  191. if self.lastVolume == 0.0, currentVolume > 0.0 {
  192. LogManager.shared.log(category: .volumeButtonSnooze, message: "Observer received initial valid volume: \(currentVolume)")
  193. self.lastVolume = currentVolume
  194. return
  195. }
  196. guard self.lastVolume > 0.0 else { return }
  197. let volumeDifference = abs(currentVolume - self.lastVolume)
  198. if volumeDifference > self.volumeButtonPressThreshold {
  199. let timeSinceAlarmStart = now.timeIntervalSince(alarmStartTime)
  200. // Ignore volume changes from the alarm system's own ramp-up.
  201. if timeSinceAlarmStart < 2.0, currentVolume > self.lastVolume {
  202. if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 {
  203. self.lastVolume = currentVolume
  204. return
  205. }
  206. }
  207. self.recordVolumeChange(currentVolume: currentVolume, timestamp: now)
  208. if timeSinceAlarmStart > self.volumeButtonActivationDelay {
  209. if let lastPress = self.lastVolumeButtonPressTime {
  210. let timeSinceLastPress = now.timeIntervalSince(lastPress)
  211. if timeSinceLastPress < self.volumeButtonCooldown {
  212. self.lastVolume = currentVolume
  213. return
  214. }
  215. }
  216. if self.isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) {
  217. self.snoozeActiveAlarm()
  218. }
  219. }
  220. }
  221. self.lastVolume = currentVolume
  222. }
  223. }
  224. func stopMonitoring() {
  225. guard isMonitoring else { return }
  226. LogManager.shared.log(category: .volumeButtonSnooze, message: "Invalidating volume observer.")
  227. // Invalidate the observer to stop receiving notifications and prevent memory leaks.
  228. volumeObserver?.invalidate()
  229. volumeObserver = nil
  230. // Disable remote command center
  231. disableRemoteCommandCenter()
  232. isMonitoring = false
  233. lastVolume = 0.0 // Reset for the next alarm.
  234. }
  235. }