| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- // LoopFollow
- // VolumeButtonHandler.swift
- import AVFoundation
- import Combine
- import Foundation
- import MediaPlayer
- import UIKit
- class VolumeButtonHandler: NSObject {
- static let shared = VolumeButtonHandler()
- // Volume button snoozer activation delay in seconds
- private let volumeButtonActivationDelay: TimeInterval = 0.9
- // Volume button detection parameters
- private let volumeButtonPressThreshold: Float = 0.02
- private let volumeButtonPressTimeWindow: TimeInterval = 0.3
- private let volumeButtonCooldown: TimeInterval = 0.5
- // KVO observer for system volume
- private var volumeObserver: NSKeyValueObservation?
- private var lastVolume: Float = 0.0
- private var isMonitoring = false
- private var alarmStartTime: Date?
- private var lastVolumeButtonPressTime: Date?
- // Button press detection
- private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = []
- private var lastSignificantVolumeChange: Date?
- private var volumeChangePattern: [TimeInterval] = []
- // Remote command center for handling bluetooth/CarPlay buttons
- private var remoteCommandsEnabled = false
- private var cancellables = Set<AnyCancellable>()
- override private init() {
- super.init()
- Observable.shared.alarmSoundPlaying.$value
- .removeDuplicates()
- .sink { [weak self] alarmSoundPlaying in
- guard let self = self else { return }
- if alarmSoundPlaying {
- self.alarmStarted()
- } else {
- self.alarmStopped()
- }
- }
- .store(in: &cancellables)
- }
- private func recordVolumeChange(currentVolume: Float, timestamp: Date) {
- recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp))
- let cutoffTime = timestamp.timeIntervalSinceReferenceDate - volumeButtonPressTimeWindow
- recentVolumeChanges = recentVolumeChanges.filter { $0.timestamp.timeIntervalSinceReferenceDate > cutoffTime }
- if let lastChange = lastSignificantVolumeChange {
- let timeSinceLastChange = timestamp.timeIntervalSince(lastChange)
- volumeChangePattern.append(timeSinceLastChange)
- if volumeChangePattern.count > 5 {
- volumeChangePattern.removeFirst()
- }
- }
- lastSignificantVolumeChange = timestamp
- }
- private func isLikelyVolumeButtonPress(volumeDifference: Float, timestamp: Date) -> Bool {
- let isReasonableChange = volumeDifference >= 0.03 && volumeDifference <= 0.12
- let isDiscreteChange = recentVolumeChanges.count <= 2
- let hasConsistentTiming = volumeChangePattern.isEmpty || volumeChangePattern.last! >= 0.15
- let isNotRapidSequence = recentVolumeChanges.count < 3 ||
- (recentVolumeChanges.count >= 3 &&
- recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }.enumerated().dropFirst().allSatisfy { index, timestamp in
- let previousTimestamp = recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }[index - 1]
- return timestamp - previousTimestamp > 0.08
- })
- return isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence
- }
- private func snoozeActiveAlarm() {
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Snoozing alarm")
- lastVolumeButtonPressTime = Date()
- AlarmManager.shared.performSnooze()
- let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
- impactFeedback.impactOccurred()
- }
- private func alarmStarted() {
- guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return }
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected, setting up volume observer.")
- alarmStartTime = Date()
- recentVolumeChanges.removeAll()
- lastSignificantVolumeChange = nil
- volumeChangePattern.removeAll()
- startMonitoring()
- }
- private func alarmStopped() {
- guard isMonitoring else { return }
- alarmStartTime = nil
- stopMonitoring()
- recentVolumeChanges.removeAll()
- lastSignificantVolumeChange = nil
- volumeChangePattern.removeAll()
- }
- private func setupRemoteCommandCenter() {
- guard !remoteCommandsEnabled else { return }
- let commandCenter = MPRemoteCommandCenter.shared()
- // Log current audio route to help with debugging
- let currentRoute = AVAudioSession.sharedInstance().currentRoute
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Audio route: \(currentRoute.outputs.map { $0.portName }.joined(separator: ", "))")
- // Enable pause command - handles play/pause button on bluetooth devices and CarPlay
- commandCenter.pauseCommand.isEnabled = true
- commandCenter.pauseCommand.addTarget { [weak self] _ in
- guard let self = self else { return .commandFailed }
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Pause command received from remote")
- // Check if alarm is currently active and activation delay has passed
- if let alarmStartTime = self.alarmStartTime {
- let timeSinceAlarmStart = Date().timeIntervalSince(alarmStartTime)
- if timeSinceAlarmStart > self.volumeButtonActivationDelay {
- // Check cooldown
- if let lastPress = self.lastVolumeButtonPressTime {
- let timeSinceLastPress = Date().timeIntervalSince(lastPress)
- if timeSinceLastPress < self.volumeButtonCooldown {
- return .success
- }
- }
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command pause received - snoozing alarm")
- self.snoozeActiveAlarm()
- return .success
- }
- }
- return .commandFailed
- }
- // Enable play command as well for symmetry
- commandCenter.playCommand.isEnabled = true
- commandCenter.playCommand.addTarget { [weak self] _ in
- guard let self = self else { return .commandFailed }
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Play command received from remote")
- if let alarmStartTime = self.alarmStartTime {
- let timeSinceAlarmStart = Date().timeIntervalSince(alarmStartTime)
- if timeSinceAlarmStart > self.volumeButtonActivationDelay {
- if let lastPress = self.lastVolumeButtonPressTime {
- let timeSinceLastPress = Date().timeIntervalSince(lastPress)
- if timeSinceLastPress < self.volumeButtonCooldown {
- return .success
- }
- }
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command play received - snoozing alarm")
- self.snoozeActiveAlarm()
- return .success
- }
- }
- return .commandFailed
- }
- // Enable toggle play/pause command - common on many bluetooth devices
- commandCenter.togglePlayPauseCommand.isEnabled = true
- commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
- guard let self = self else { return .commandFailed }
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Toggle play/pause command received from remote")
- if let alarmStartTime = self.alarmStartTime {
- let timeSinceAlarmStart = Date().timeIntervalSince(alarmStartTime)
- if timeSinceAlarmStart > self.volumeButtonActivationDelay {
- if let lastPress = self.lastVolumeButtonPressTime {
- let timeSinceLastPress = Date().timeIntervalSince(lastPress)
- if timeSinceLastPress < self.volumeButtonCooldown {
- return .success
- }
- }
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command toggle play/pause received - snoozing alarm")
- self.snoozeActiveAlarm()
- return .success
- }
- }
- return .commandFailed
- }
- remoteCommandsEnabled = true
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command center configured for bluetooth/CarPlay button handling")
- }
- private func disableRemoteCommandCenter() {
- guard remoteCommandsEnabled else { return }
- let commandCenter = MPRemoteCommandCenter.shared()
- commandCenter.pauseCommand.isEnabled = false
- commandCenter.playCommand.isEnabled = false
- commandCenter.togglePlayPauseCommand.isEnabled = false
- // Remove all targets
- commandCenter.pauseCommand.removeTarget(nil)
- commandCenter.playCommand.removeTarget(nil)
- commandCenter.togglePlayPauseCommand.removeTarget(nil)
- remoteCommandsEnabled = false
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Remote command center disabled")
- }
- func startMonitoring() {
- guard !isMonitoring else { return }
- isMonitoring = true
- // Setup remote command center for bluetooth/CarPlay button handling
- setupRemoteCommandCenter()
- volumeObserver = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] session, _ in
- guard let self = self, let alarmStartTime = self.alarmStartTime else { return }
- let currentVolume = session.outputVolume
- let now = Date()
- // On the first observation, capture the initial volume when the audio session
- // becomes active. This solves the race condition. We then return to avoid
- // treating this initial setup as a user-initiated button press.
- if self.lastVolume == 0.0, currentVolume > 0.0 {
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Observer received initial valid volume: \(currentVolume)")
- self.lastVolume = currentVolume
- return
- }
- guard self.lastVolume > 0.0 else { return }
- let volumeDifference = abs(currentVolume - self.lastVolume)
- if volumeDifference > self.volumeButtonPressThreshold {
- let timeSinceAlarmStart = now.timeIntervalSince(alarmStartTime)
- // Ignore volume changes from the alarm system's own ramp-up.
- if timeSinceAlarmStart < 2.0, currentVolume > self.lastVolume {
- if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 {
- self.lastVolume = currentVolume
- return
- }
- }
- self.recordVolumeChange(currentVolume: currentVolume, timestamp: now)
- if timeSinceAlarmStart > self.volumeButtonActivationDelay {
- if let lastPress = self.lastVolumeButtonPressTime {
- let timeSinceLastPress = now.timeIntervalSince(lastPress)
- if timeSinceLastPress < self.volumeButtonCooldown {
- self.lastVolume = currentVolume
- return
- }
- }
- if self.isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) {
- self.snoozeActiveAlarm()
- }
- }
- }
- self.lastVolume = currentVolume
- }
- }
- func stopMonitoring() {
- guard isMonitoring else { return }
- LogManager.shared.log(category: .volumeButtonSnooze, message: "Invalidating volume observer.")
- // Invalidate the observer to stop receiving notifications and prevent memory leaks.
- volumeObserver?.invalidate()
- volumeObserver = nil
- // Disable remote command center
- disableRemoteCommandCenter()
- isMonitoring = false
- lastVolume = 0.0 // Reset for the next alarm.
- }
- }
|