Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion LoopFollow/Alarm/Alarm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,83 @@ struct Alarm: Identifiable, Codable, Equatable {
/// When the sound should repeat
var repeatSoundOption: RepeatSoundOption = .always

/// Delay in seconds between repeated alarm sounds (0 = no delay, only applies when repeating)
var soundDelay: Int = 0

/// When is the alarm active
var activeOption: ActiveOption = .always

// MARK: - Codable (Custom implementation for backward compatibility)

enum CodingKeys: String, CodingKey {
case id, type, name, isEnabled, snoozedUntil
case aboveBG, belowBG, threshold, predictiveMinutes, delta
case persistentMinutes, monitoringWindow, soundFile
case snoozeDuration, playSoundOption, repeatSoundOption
case soundDelay, activeOption
case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
case missedBolusIgnoreUnderGrams, missedBolusIgnoreUnderBG
case bolusCountThreshold, bolusWindowMinutes
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

id = try container.decode(UUID.self, forKey: .id)
type = try container.decode(AlarmType.self, forKey: .type)
name = try container.decode(String.self, forKey: .name)
isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil)
aboveBG = try container.decodeIfPresent(Double.self, forKey: .aboveBG)
belowBG = try container.decodeIfPresent(Double.self, forKey: .belowBG)
threshold = try container.decodeIfPresent(Double.self, forKey: .threshold)
predictiveMinutes = try container.decodeIfPresent(Int.self, forKey: .predictiveMinutes)
delta = try container.decodeIfPresent(Double.self, forKey: .delta)
persistentMinutes = try container.decodeIfPresent(Int.self, forKey: .persistentMinutes)
monitoringWindow = try container.decodeIfPresent(Int.self, forKey: .monitoringWindow)
soundFile = try container.decode(SoundFile.self, forKey: .soundFile)
snoozeDuration = try container.decodeIfPresent(Int.self, forKey: .snoozeDuration) ?? 5
playSoundOption = try container.decodeIfPresent(PlaySoundOption.self, forKey: .playSoundOption) ?? .always
repeatSoundOption = try container.decodeIfPresent(RepeatSoundOption.self, forKey: .repeatSoundOption) ?? .always
// Handle backward compatibility: default to 0 if soundDelay is missing
soundDelay = try container.decodeIfPresent(Int.self, forKey: .soundDelay) ?? 0
activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
missedBolusPrebolusWindow = try container.decodeIfPresent(Int.self, forKey: .missedBolusPrebolusWindow)
missedBolusIgnoreSmallBolusUnits = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreSmallBolusUnits)
missedBolusIgnoreUnderGrams = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderGrams)
missedBolusIgnoreUnderBG = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderBG)
bolusCountThreshold = try container.decodeIfPresent(Int.self, forKey: .bolusCountThreshold)
bolusWindowMinutes = try container.decodeIfPresent(Int.self, forKey: .bolusWindowMinutes)
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(type, forKey: .type)
try container.encode(name, forKey: .name)
try container.encode(isEnabled, forKey: .isEnabled)
try container.encodeIfPresent(snoozedUntil, forKey: .snoozedUntil)
try container.encodeIfPresent(aboveBG, forKey: .aboveBG)
try container.encodeIfPresent(belowBG, forKey: .belowBG)
try container.encodeIfPresent(threshold, forKey: .threshold)
try container.encodeIfPresent(predictiveMinutes, forKey: .predictiveMinutes)
try container.encodeIfPresent(delta, forKey: .delta)
try container.encodeIfPresent(persistentMinutes, forKey: .persistentMinutes)
try container.encodeIfPresent(monitoringWindow, forKey: .monitoringWindow)
try container.encode(soundFile, forKey: .soundFile)
try container.encode(snoozeDuration, forKey: .snoozeDuration)
try container.encode(playSoundOption, forKey: .playSoundOption)
try container.encode(repeatSoundOption, forKey: .repeatSoundOption)
try container.encode(soundDelay, forKey: .soundDelay)
try container.encode(activeOption, forKey: .activeOption)
try container.encodeIfPresent(missedBolusPrebolusWindow, forKey: .missedBolusPrebolusWindow)
try container.encodeIfPresent(missedBolusIgnoreSmallBolusUnits, forKey: .missedBolusIgnoreSmallBolusUnits)
try container.encodeIfPresent(missedBolusIgnoreUnderGrams, forKey: .missedBolusIgnoreUnderGrams)
try container.encodeIfPresent(missedBolusIgnoreUnderBG, forKey: .missedBolusIgnoreUnderBG)
try container.encodeIfPresent(bolusCountThreshold, forKey: .bolusCountThreshold)
try container.encodeIfPresent(bolusWindowMinutes, forKey: .bolusWindowMinutes)
}

// ─────────────────────────────────────────────────────────────
// Missed‑Bolus‑specific settings
// ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -180,7 +254,9 @@ struct Alarm: Identifiable, Codable, Equatable {

if playSound {
AlarmSound.setSoundFile(str: soundFile.rawValue)
AlarmSound.play(repeating: shouldRepeat)
// Only use delay if repeating is enabled, otherwise delay doesn't make sense
let delay = shouldRepeat ? soundDelay : 0
AlarmSound.play(repeating: shouldRepeat, delay: delay)
}
}

Expand Down
18 changes: 18 additions & 0 deletions LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ struct AlarmAudioSection: View {
allowed: RepeatSoundOption.allowed(for: alarm.activeOption)
)
}

Stepper(
value: $alarm.soundDelay,
in: 0 ... 60,
step: 5
) {
HStack {
Text("Delay Between Sounds")
Spacer()
if alarm.soundDelay == 0 {
Text("Off")
.foregroundColor(.secondary)
} else {
Text("\(alarm.soundDelay) sec")
.foregroundColor(.secondary)
}
}
}
}.onChange(of: alarm.activeOption) { newActive in
let playAllowed = PlaySoundOption.allowed(for: newActive)
if !playAllowed.contains(alarm.playSoundOption) {
Expand Down
64 changes: 61 additions & 3 deletions LoopFollow/Controllers/AlarmSound.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class AlarmSound {
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,
Expand Down Expand Up @@ -70,6 +72,7 @@ class AlarmSound {
static func stop() {
Observable.shared.alarmSoundPlaying.value = false

repeatDelay = 0
audioPlayer?.stop()
audioPlayer = nil

Expand Down Expand Up @@ -108,21 +111,27 @@ class AlarmSound {
}
}

static func play(repeating: Bool) {
static func play(repeating: Bool, delay: Int = 0) {
guard !isPlaying else {
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

do {
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
audioPlayer!.delegate = audioPlayerDelegate

try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category(rawValue: convertFromAVAudioSessionCategory(AVAudioSession.Category.playback)))
try AVAudioSession.sharedInstance().setActive(true)

audioPlayer!.numberOfLoops = repeating ? -1 : 0
// 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 {
Expand All @@ -133,12 +142,16 @@ class AlarmSound {
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")
LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(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")
Expand All @@ -152,6 +165,45 @@ class AlarmSound {
}
}

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

try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)

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
Expand Down Expand Up @@ -222,7 +274,13 @@ 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)
Observable.shared.alarmSoundPlaying.value = false

// 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. */
Expand Down