// 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() 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. } }