| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- // LoopFollow
- // AlarmSound.swift
- import AVFoundation
- import Foundation
- import MediaPlayer
- import UIKit
- /*
- * Class that handles the playing and the volume of the alarm sound.
- */
- class AlarmSound {
- static var isPlaying: Bool {
- return audioPlayer?.isPlaying == true
- }
- static var isMuted: Bool {
- return muted
- }
- static var whichAlarm: String = "none"
- static var soundFile = "Indeed"
- static var isTesting: Bool = false
- fileprivate static var systemOutputVolumeBeforeOverride: Float?
- fileprivate static var soundURL = Bundle.main.url(forResource: "Indeed", withExtension: "caf")!
- fileprivate static var audioPlayer: AVAudioPlayer?
- fileprivate static let audioPlayerDelegate = AudioPlayerDelegate()
- fileprivate static var muted = false
- fileprivate static var alarmPlayingForTimer = Timer()
- fileprivate static let alarmPlayingForInterval = 290
- fileprivate static var repeatDelay: TimeInterval = 0
- fileprivate func startAlarmPlayingForTimer(time: TimeInterval) {
- AlarmSound.alarmPlayingForTimer = Timer.scheduledTimer(timeInterval: time,
- target: self,
- selector: #selector(AlarmSound.alarmPlayingForTimerDidEnd(_:)),
- userInfo: nil,
- repeats: true)
- }
- @objc func alarmPlayingForTimerDidEnd(_: Timer) {
- if !AlarmSound.isPlaying { return }
- AlarmSound.stop()
- }
- /*
- * Sets the audio volume to 0.
- */
- static func muteVolume() {
- audioPlayer?.volume = 0
- muted = true
- restoreSystemOutputVolume()
- }
- static func setSoundFile(str: String) {
- setSoundFile(.builtin(str))
- }
- static func setSoundFile(_ file: SoundFile) {
- if let resolved = resolveURL(for: file) {
- soundURL = resolved
- return
- }
- LogManager.shared.log(
- category: .alarm,
- message: "AlarmSound - missing sound for \(file.id); falling back to \(SoundFile.fallback.id)"
- )
- if let fallback = resolveURL(for: .fallback) {
- soundURL = fallback
- }
- }
- private static func resolveURL(for file: SoundFile) -> URL? {
- switch file {
- case let .builtin(name):
- return Bundle.main.url(forResource: name, withExtension: "caf")
- case let .custom(uuid):
- return CustomSoundStore.shared.url(for: uuid)
- }
- }
- /*
- * Sets the volume of the alarm back to the volume before it has been muted.
- */
- static func unmuteVolume() {
- audioPlayer?.volume = 1.0
- muted = false
- }
- static func stop() {
- Observable.shared.alarmSoundPlaying.value = false
- repeatDelay = 0
- audioPlayer?.stop()
- audioPlayer = nil
- restoreSystemOutputVolume()
- }
- static func playTest() {
- guard !isPlaying else {
- return
- }
- do {
- audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
- audioPlayer!.delegate = audioPlayerDelegate
- activateAudioSessionWithFallback()
- audioPlayer?.numberOfLoops = 0
- if !audioPlayer!.prepareToPlay() {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed preparing to play")
- }
- if audioPlayer!.play() {
- if !isPlaying {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play")
- LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(audioPlayer!.rate)")
- }
- } else {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play")
- }
- } catch {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - unable to play sound; error: \(error)")
- }
- }
- static func play(repeating: Bool, delay: Int = 0) {
- guard !isPlaying else {
- return
- }
- // If repeating with delay, we'll handle it manually via the delegate
- // Only set repeatDelay if both repeating and delay > 0
- repeatDelay = (repeating && delay > 0) ? TimeInterval(delay) : 0
- do {
- audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
- audioPlayer!.delegate = audioPlayerDelegate
- activateAudioSessionWithFallback()
- // Only use numberOfLoops if we're not using delay-based repeating
- // When repeatDelay > 0, we play once and then use the delegate to schedule the next play with delay
- audioPlayer!.numberOfLoops = (repeating && repeatDelay == 0) ? -1 : 0
- // Store existing volume
- if systemOutputVolumeBeforeOverride == nil {
- systemOutputVolumeBeforeOverride = AVAudioSession.sharedInstance().outputVolume
- }
- if !audioPlayer!.prepareToPlay() {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed preparing to play")
- }
- // First sound plays immediately - delay only applies between repeated sounds
- if audioPlayer!.play() {
- if !isPlaying {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play (rate \(audioPlayer!.rate))")
- } else {
- Observable.shared.alarmSoundPlaying.value = true
- if repeatDelay > 0 {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - first sound playing immediately, delay (\(repeatDelay)s) will apply between repeats")
- }
- }
- } else {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play")
- }
- if Storage.shared.alarmConfiguration.value.overrideSystemOutputVolume {
- MPVolumeView.setVolume(Storage.shared.alarmConfiguration.value.forcedOutputVolume)
- }
- } catch {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - unable to play sound; error: \(error)")
- }
- }
- fileprivate static func playNextWithDelay() {
- guard repeatDelay > 0 else {
- return
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + repeatDelay) {
- // Check if we should still be playing (user might have stopped it)
- guard repeatDelay > 0 else {
- return
- }
- // Clean up the previous player
- audioPlayer?.stop()
- audioPlayer = nil
- do {
- audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
- audioPlayer!.delegate = audioPlayerDelegate
- activateAudioSessionWithFallback()
- audioPlayer!.numberOfLoops = 0
- if !audioPlayer!.prepareToPlay() {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed preparing to play (delayed repeat)")
- }
- if audioPlayer!.play() {
- Observable.shared.alarmSoundPlaying.value = true
- } else {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play (delayed repeat)")
- }
- } catch {
- LogManager.shared.log(category: .alarm, message: "AlarmSound - unable to play sound (delayed repeat); error: \(error)")
- }
- }
- }
- static func playTerminated() {
- guard !isPlaying else {
- return
- }
- do {
- audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
- audioPlayer!.delegate = audioPlayerDelegate
- activateAudioSessionWithFallback()
- // Play endless loops
- audioPlayer!.numberOfLoops = 2
- // Store existing volume
- if systemOutputVolumeBeforeOverride == nil {
- systemOutputVolumeBeforeOverride = AVAudioSession.sharedInstance().outputVolume
- }
- if !audioPlayer!.prepareToPlay() {
- LogManager.shared.log(category: .alarm, message: "Terminate AlarmSound - audio player failed preparing to play")
- }
- if audioPlayer!.play() {
- if !isPlaying {
- LogManager.shared.log(category: .alarm, message: "Terminate AlarmSound - not playing after calling play")
- LogManager.shared.log(category: .alarm, message: "Terminate AlarmSound - rate value: \(audioPlayer!.rate)")
- }
- } else {
- LogManager.shared.log(category: .alarm, message: "Terminate AlarmSound - audio player failed to play")
- }
- MPVolumeView.setVolume(1.0)
- } catch {
- LogManager.shared.log(category: .alarm, message: "Terminate AlarmSound - unable to play sound; error: \(error)")
- }
- }
- fileprivate static func restoreSystemOutputVolume() {
- guard Storage.shared.alarmConfiguration.value.overrideSystemOutputVolume else {
- return
- }
- // cancel any volume change observations
- // self.volumeChangeDetector.isActive = false
- // restore system output volume with its value before overriding it
- if let volumeBeforeOverride = systemOutputVolumeBeforeOverride {
- MPVolumeView.setVolume(volumeBeforeOverride)
- }
- systemOutputVolumeBeforeOverride = nil
- }
- // Background activation of a non-mixable .playback session is denied by iOS
- // (cannotInterruptOthers, 560557684) unless the app is already actively playing
- // audio. In foreground, or with Silent Tune holding a mixable session alive,
- // options: [] succeeds and lets the alarm dominate other audio. For
- // Bluetooth-heartbeat users with no Silent Tune we skip [] (it would always
- // be denied) and ladder through mixable options so activation is still
- // permitted from background. Each attempt is logged so we can see in the
- // field which fallback (if any) the user landed on.
- fileprivate static func activateAudioSessionWithFallback() {
- let isBackgroundWithoutSilentTune = UIApplication.shared.applicationState == .background
- && Storage.shared.backgroundRefreshType.value != .silentTune
- let dominate: (label: String, options: AVAudioSession.CategoryOptions) = ("[]", [])
- let duck: (label: String, options: AVAudioSession.CategoryOptions) = (".duckOthers", .duckOthers)
- let mix: (label: String, options: AVAudioSession.CategoryOptions) = (".mixWithOthers", .mixWithOthers)
- let candidates = isBackgroundWithoutSilentTune ? [duck, mix] : [dominate, duck, mix]
- for candidate in candidates {
- do {
- try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: candidate.options)
- try AVAudioSession.sharedInstance().setActive(true)
- LogManager.shared.log(category: .alarm, message: "AlarmSound - audio session active (options: \(candidate.label))")
- return
- } catch {
- let nsError = error as NSError
- LogManager.shared.log(category: .alarm, message: "AlarmSound - audio session activation failed (options: \(candidate.label)) [code \(nsError.code)]: \(error.localizedDescription)")
- }
- }
- LogManager.shared.log(category: .alarm, message: "AlarmSound - all audio session option fallbacks exhausted")
- }
- }
- class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
- /* audioPlayerDidFinishPlaying:successfully: is called when a sound has finished playing. This method is NOT called if the player is stopped due to an interruption. */
- func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) {
- LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerDidFinishPlaying (\(flag))", isDebug: true)
- // If we're repeating with delay, schedule the next play
- if AlarmSound.repeatDelay > 0 {
- AlarmSound.playNextWithDelay()
- } else {
- Observable.shared.alarmSoundPlaying.value = false
- }
- }
- /* if an error occurs while decoding it will be reported to the delegate. */
- func audioPlayerDecodeErrorDidOccur(_: AVAudioPlayer, error: Error?) {
- if let error = error {
- LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerDecodeErrorDidOccur: \(error)")
- } else {
- LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerDecodeErrorDidOccur")
- }
- }
- /* AVAudioPlayer INTERRUPTION NOTIFICATIONS ARE DEPRECATED - Use AVAudioSession instead. */
- /* audioPlayerBeginInterruption: is called when the audio session has been interrupted while the player was playing. The player will have been paused. */
- func audioPlayerBeginInterruption(_: AVAudioPlayer) {
- LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerBeginInterruption")
- Observable.shared.alarmSoundPlaying.value = false
- }
- /* audioPlayerEndInterruption:withOptions: is called when the audio session interruption has ended and this player had been interrupted while playing. */
- /* Currently the only flag is AVAudioSessionInterruptionFlags_ShouldResume. */
- func audioPlayerEndInterruption(_: AVAudioPlayer, withOptions flags: Int) {
- LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerEndInterruption withOptions: \(flags)")
- Observable.shared.alarmSoundPlaying.value = false
- }
- }
- // Helper function inserted by Swift 4.2 migrator.
- private func convertFromAVAudioSessionCategory(_ input: AVAudioSession.Category) -> String {
- return input.rawValue
- }
- extension MPVolumeView {
- static func setVolume(_ volume: Float) {
- // Need to use the MPVolumeView in order to change volume, but don't care about UI set so frame to .zero
- let volumeView = MPVolumeView(frame: .zero)
- // Search for the slider
- let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider
- // Update the slider value with the desired volume.
- DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) {
- slider?.value = volume
- }
- // Optional - Remove the HUD
- let activeWindow = UIApplication.shared.connectedScenes
- .compactMap { $0 as? UIWindowScene }
- .first { $0.activationState == .foregroundActive }?
- .windows.first(where: \.isKeyWindow)
- if let window = activeWindow {
- volumeView.alpha = 0.000001
- window.addSubview(volumeView)
- }
- }
- }
|