diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index ba131bc4f..b83383185 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -88,8 +88,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) - try AVAudioSession.sharedInstance().setActive(true) + activateAudioSessionWithFallback() audioPlayer?.numberOfLoops = 0 @@ -116,8 +115,6 @@ class AlarmSound { return } - enableAudio() - // 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 @@ -126,8 +123,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) - try AVAudioSession.sharedInstance().setActive(true) + 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 @@ -145,8 +141,7 @@ class AlarmSound { // 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") - LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(audioPlayer!.rate)") + LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play (rate \(audioPlayer!.rate))") } else { Observable.shared.alarmSoundPlaying.value = true if repeatDelay > 0 { @@ -184,8 +179,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) - try AVAudioSession.sharedInstance().setActive(true) + activateAudioSessionWithFallback() audioPlayer!.numberOfLoops = 0 @@ -213,8 +207,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) - try AVAudioSession.sharedInstance().setActive(true) + activateAudioSessionWithFallback() // Play endless loops audioPlayer!.numberOfLoops = 2 @@ -260,14 +253,35 @@ class AlarmSound { systemOutputVolumeBeforeOverride = nil } - fileprivate static func enableAudio() { - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) - try AVAudioSession.sharedInstance().setActive(true) - LogManager.shared.log(category: .alarm, message: "Audio session configured for alarm playback") - } catch { - LogManager.shared.log(category: .alarm, message: "Enable audio error: \(error)") + // 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") } }