diff --git a/Sources/engine/SoundPlayer.swift b/Sources/engine/SoundPlayer.swift new file mode 100644 index 0000000..2cdf18c --- /dev/null +++ b/Sources/engine/SoundPlayer.swift @@ -0,0 +1,80 @@ +// +// SoundPlayer.swift +// 摸鱼侠 plugin v0.2.1 +// +// Sit-reminder alert tone. The bundled UN notification sound is a +// ~1s ding that's easy to miss in a meeting — for sit alerts we want +// a 15s nag that makes you actually stand up. +// +// Implementation: repeat the macOS system "Morse" sound (0.7s) once +// per second for 15 ticks. Morse is the most distinctive notification +// sound shipped with macOS — staccato 3-tone "S O S" pattern that +// pierces background noise better than Glass/Hero/Ping/Funk/etc. +// +// Plays via NSSound (not UNNotificationContent.sound) because UN +// sounds are limited to ~30s but get truncated to ~5s on macOS, and +// attach a single tone to a single notification — no way to fire a +// repeated sequence from UN. +// + +import AppKit +import Foundation + +@MainActor +final class SoundPlayer { + static let shared = SoundPlayer() + private init() {} + + /// System Morse sound — 0.7s staccato 3-tone "S". + /// Shipped on every macOS since at least 10.0; safe to hard-code path. + private let morseURL = URL(fileURLWithPath: "/System/Library/Sounds/Morse.aiff") + + private var timer: Timer? + private var remaining: Int = 0 + private var currentSound: NSSound? + + /// Repeated Morse alert: play `count` times at 1Hz. Each play is a + /// fresh NSSound so previous-in-flight playback doesn't get stomped + /// (NSSound.play() is non-blocking and the 0.7s tone overlaps with + /// the next-second silence — acceptable acoustic gap). + /// + /// Calling while a previous alert is still running cancels the old + /// one and restarts from `count`. This keeps "user sat back down + + /// trigger fired again 45min later" clean. + func playMorseSitAlert(count: Int = 15) { + stop() + remaining = count + playOnce() // fire immediately, then 1Hz repeat + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in self?.playOnce() } + } + } + + /// Cancel any in-flight alert. Called by WorkerStore.sitStop() so + /// "user stopped sit monitoring" silences the nag immediately. + func stop() { + timer?.invalidate() + timer = nil + remaining = 0 + currentSound?.stop() + currentSound = nil + } + + private func playOnce() { + guard remaining > 0 else { + stop() + return + } + remaining -= 1 + // byReference: true — load lazily from the system path each call. + // The cost is negligible (< 1ms) and avoids keeping a 200KB AIFF + // resident across the 45min idle stretch between alerts. + if let sound = NSSound(contentsOf: morseURL, byReference: true) { + sound.play() + currentSound = sound + } else { + WorkerDebugLog.write("SoundPlayer: failed to load \(morseURL.path)") + stop() + } + } +} diff --git a/Sources/engine/WorkerStore.swift b/Sources/engine/WorkerStore.swift index c7da2ed..178a2a1 100644 --- a/Sources/engine/WorkerStore.swift +++ b/Sources/engine/WorkerStore.swift @@ -256,7 +256,11 @@ final class WorkerStore: ObservableObject { title: "该起来动一下了", body: "你已连续坐了 \(sitTriggerMin) 分钟,起身喝口水吧。" ) - WorkerDebugLog.write("sit threshold notification fired (\(sitElapsedSec)s)") + // 1Hz × 15 ticks of system Morse tone — the UN + // notification ding is too easy to miss in a meeting, + // so the sit alert gets a louder, longer signal. + SoundPlayer.shared.playMorseSitAlert(count: 15) + WorkerDebugLog.write("sit threshold notification + morse alert fired (\(sitElapsedSec)s)") } } @@ -463,6 +467,9 @@ final class WorkerStore: ObservableObject { defaults.set(false, forKey: K.sitEnabled) defaults.set(0, forKey: K.sitAccumActive) defaults.set(0.0, forKey: K.sitLastNotified) + // Silence any in-flight Morse alert from a prior trigger so the + // user stopping monitoring also stops the nag immediately. + SoundPlayer.shared.stop() } func sitSetTrigger(_ min: Int) {