Files
nucleic/Sources/NucleicApp/ChatAlarm.swift
T
abkslmandnucleic 5e00404575 nvrsion: promote trunk to dev
Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
2026-06-27 19:33:40 -07:00

66 lines
2.8 KiB
Swift

import AppKit
import NucleicCore
/// Drives the "escalating alarm": while a chat needs the user and they haven't opened it,
/// repeat the "Submarine" system sound — with a short gap between plays — until they return
/// to that chat. `AppStore` tracks *which* chats are blocked-and-unviewed and calls
/// `setActive(_:)` as that set flips between empty and non-empty (`AppStore.chatAlarmHook`,
/// installed in `NucleicApp.init`); this type owns the repeating playback and honors the
/// off-by-default Settings switch. A singleton so the one alarm loop is shared app-wide.
@MainActor
final class ChatAlarm {
static let shared = ChatAlarm()
private init() {}
/// Silence between consecutive plays, so the sound repeats as a deliberate pulse rather
/// than the next copy stacking on top of the last while it's still sounding.
private let gap = Duration.seconds(0.5)
/// The running repeat loop, or nil when silent. Cancelling it stops scheduling new plays;
/// `current?.stop()` cuts off whatever is sounding right now for instant silence on return.
private var loop: Task<Void, Never>?
private var current: NSSound?
/// Start or stop the alarm. Honors the Settings switch: with the alarm turned off,
/// `active == true` is a no-op, so a blocked chat still rings only its one-shot "Pop" cue.
/// `active == false` always stops, so toggling the switch off mid-alarm silences it at once.
func setActive(_ active: Bool) {
if active && ChatStatusSounds.escalatingAlarm {
start()
} else {
stop()
}
}
private func start() {
guard loop == nil else { return } // already ringing — don't stack loops
loop = Task { @MainActor [weak self] in
guard let self else { return }
// Pace each cycle by the clip's own length plus the gap, so a ~1.5s sound doesn't
// pile copies on itself; fall back to a sane length if the system can't report one.
let probe = NSSound(named: NSSound.Name(ChatStatusSounds.alarmSoundName))
let clip: Duration = (probe?.duration ?? 0) > 0 ? .seconds(probe!.duration) : .seconds(1.5)
while !Task.isCancelled {
self.ring()
try? await Task.sleep(for: clip + self.gap)
}
}
}
private func stop() {
loop?.cancel()
loop = nil
current?.stop()
current = nil
}
/// Play one fresh copy of the alarm sound, holding the reference so `stop()` can cut it off
/// the instant the user returns to the chat.
private func ring() {
guard let sound = NSSound(named: NSSound.Name(ChatStatusSounds.alarmSoundName))?.copy() as? NSSound
else { return }
current = sound
sound.play()
}
}