Add callbacks, crescendo sound and better configs
This commit is contained in:
24
db/db.go
24
db/db.go
@@ -207,6 +207,15 @@ type Settings struct {
|
|||||||
ServerPassword string // Password for authenticating to remote server
|
ServerPassword string // Password for authenticating to remote server
|
||||||
APIPassword string // Password required for incoming API requests (host mode)
|
APIPassword string // Password required for incoming API requests (host mode)
|
||||||
PollSeconds int // How often to poll server in client mode
|
PollSeconds int // How often to poll server in client mode
|
||||||
|
|
||||||
|
// Crescendo volume control (PulseAudio)
|
||||||
|
CrescendoEnabled bool // Whether to gradually raise volume
|
||||||
|
CrescendoStartPct int // Starting volume percentage (0-100)
|
||||||
|
CrescendoEndPct int // Ending volume percentage (0-100)
|
||||||
|
CrescendoDurationS int // Seconds to ramp from start to end
|
||||||
|
|
||||||
|
// Callback script
|
||||||
|
CallbackScript string // Path to script called on alarm events (start, dismiss, snooze)
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultSettings() Settings {
|
func DefaultSettings() Settings {
|
||||||
@@ -223,6 +232,11 @@ func DefaultSettings() Settings {
|
|||||||
ServerPassword: "",
|
ServerPassword: "",
|
||||||
APIPassword: "",
|
APIPassword: "",
|
||||||
PollSeconds: 5,
|
PollSeconds: 5,
|
||||||
|
CrescendoEnabled: false,
|
||||||
|
CrescendoStartPct: 20,
|
||||||
|
CrescendoEndPct: 100,
|
||||||
|
CrescendoDurationS: 60,
|
||||||
|
CallbackScript: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +254,11 @@ func (s *Store) LoadSettings() Settings {
|
|||||||
cfg.ServerPassword = s.GetSetting("server_password", cfg.ServerPassword)
|
cfg.ServerPassword = s.GetSetting("server_password", cfg.ServerPassword)
|
||||||
cfg.APIPassword = s.GetSetting("api_password", cfg.APIPassword)
|
cfg.APIPassword = s.GetSetting("api_password", cfg.APIPassword)
|
||||||
cfg.PollSeconds = s.getSettingInt("poll_seconds", cfg.PollSeconds)
|
cfg.PollSeconds = s.getSettingInt("poll_seconds", cfg.PollSeconds)
|
||||||
|
cfg.CrescendoEnabled = s.GetSetting("crescendo_enabled", "false") == "true"
|
||||||
|
cfg.CrescendoStartPct = s.getSettingInt("crescendo_start_pct", cfg.CrescendoStartPct)
|
||||||
|
cfg.CrescendoEndPct = s.getSettingInt("crescendo_end_pct", cfg.CrescendoEndPct)
|
||||||
|
cfg.CrescendoDurationS = s.getSettingInt("crescendo_duration_s", cfg.CrescendoDurationS)
|
||||||
|
cfg.CallbackScript = s.GetSetting("callback_script", cfg.CallbackScript)
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +276,11 @@ func (s *Store) SaveSettings(cfg Settings) error {
|
|||||||
"server_password": cfg.ServerPassword,
|
"server_password": cfg.ServerPassword,
|
||||||
"api_password": cfg.APIPassword,
|
"api_password": cfg.APIPassword,
|
||||||
"poll_seconds": fmt.Sprintf("%d", cfg.PollSeconds),
|
"poll_seconds": fmt.Sprintf("%d", cfg.PollSeconds),
|
||||||
|
"crescendo_enabled": fmt.Sprintf("%t", cfg.CrescendoEnabled),
|
||||||
|
"crescendo_start_pct": fmt.Sprintf("%d", cfg.CrescendoStartPct),
|
||||||
|
"crescendo_end_pct": fmt.Sprintf("%d", cfg.CrescendoEndPct),
|
||||||
|
"crescendo_duration_s": fmt.Sprintf("%d", cfg.CrescendoDurationS),
|
||||||
|
"callback_script": cfg.CallbackScript,
|
||||||
}
|
}
|
||||||
for k, v := range pairs {
|
for k, v := range pairs {
|
||||||
if err := s.SetSetting(k, v); err != nil {
|
if err := s.SetSetting(k, v); err != nil {
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
package player
|
package player
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CrescendoConfig holds volume ramp settings.
|
||||||
|
type CrescendoConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
StartPct int // 0-100
|
||||||
|
EndPct int // 0-100
|
||||||
|
DurationS int // seconds to ramp
|
||||||
|
}
|
||||||
|
|
||||||
// Player handles alarm sound playback by shelling out to system audio tools.
|
// Player handles alarm sound playback by shelling out to system audio tools.
|
||||||
type Player struct {
|
type Player struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
|
crescStopCh chan struct{}
|
||||||
|
crescDoneCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Player {
|
func New() *Player {
|
||||||
@@ -60,6 +71,11 @@ func findPlayer() (string, func(string) []string) {
|
|||||||
|
|
||||||
// PlayLoop starts playing a sound file in a loop until Stop() is called.
|
// PlayLoop starts playing a sound file in a loop until Stop() is called.
|
||||||
func (p *Player) PlayLoop(path string) {
|
func (p *Player) PlayLoop(path string) {
|
||||||
|
p.PlayLoopWithCrescendo(path, CrescendoConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayLoopWithCrescendo starts playing with optional volume ramp.
|
||||||
|
func (p *Player) PlayLoopWithCrescendo(path string, cresc CrescendoConfig) {
|
||||||
p.Stop() // kill any previous playback and wait for it to finish
|
p.Stop() // kill any previous playback and wait for it to finish
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@@ -69,6 +85,19 @@ func (p *Player) PlayLoop(path string) {
|
|||||||
doneCh := p.doneCh
|
doneCh := p.doneCh
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Start crescendo if enabled and pactl is available
|
||||||
|
if cresc.Enabled && cresc.DurationS > 0 {
|
||||||
|
if _, err := exec.LookPath("pactl"); err == nil {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.crescStopCh = make(chan struct{})
|
||||||
|
p.crescDoneCh = make(chan struct{})
|
||||||
|
crescStopCh := p.crescStopCh
|
||||||
|
crescDoneCh := p.crescDoneCh
|
||||||
|
p.mu.Unlock()
|
||||||
|
go p.crescendoLoop(cresc, crescStopCh, crescDoneCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolved := resolveSound(path)
|
resolved := resolveSound(path)
|
||||||
if resolved == "" {
|
if resolved == "" {
|
||||||
go p.bellLoop(stopCh, doneCh)
|
go p.bellLoop(stopCh, doneCh)
|
||||||
@@ -146,15 +175,74 @@ func (p *Player) bellLoop(stopCh, doneCh chan struct{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// crescendoLoop gradually raises PulseAudio volume from StartPct to EndPct over DurationS seconds.
|
||||||
|
func (p *Player) crescendoLoop(cfg CrescendoConfig, stopCh, doneCh chan struct{}) {
|
||||||
|
defer close(doneCh)
|
||||||
|
|
||||||
|
startPct := cfg.StartPct
|
||||||
|
endPct := cfg.EndPct
|
||||||
|
if startPct < 0 {
|
||||||
|
startPct = 0
|
||||||
|
}
|
||||||
|
if endPct > 150 { // allow some overdrive but not ridiculous
|
||||||
|
endPct = 150
|
||||||
|
}
|
||||||
|
if startPct >= endPct {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial volume
|
||||||
|
setVolume(startPct)
|
||||||
|
|
||||||
|
steps := cfg.DurationS
|
||||||
|
if steps < 1 {
|
||||||
|
steps = 1
|
||||||
|
}
|
||||||
|
stepSize := float64(endPct-startPct) / float64(steps)
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
currentPct := float64(startPct)
|
||||||
|
for i := 0; i < steps; i++ {
|
||||||
|
select {
|
||||||
|
case <-stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
currentPct += stepSize
|
||||||
|
setVolume(int(currentPct))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure we hit the target
|
||||||
|
setVolume(endPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setVolume sets the default PulseAudio sink volume to the given percentage.
|
||||||
|
func setVolume(pct int) {
|
||||||
|
_ = exec.Command("pactl", "set-sink-volume", "@DEFAULT_SINK@", fmt.Sprintf("%d%%", pct)).Run()
|
||||||
|
}
|
||||||
|
|
||||||
// Stop stops any currently playing sound and waits for the playback goroutine to exit.
|
// Stop stops any currently playing sound and waits for the playback goroutine to exit.
|
||||||
func (p *Player) Stop() {
|
func (p *Player) Stop() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
stopCh := p.stopCh
|
stopCh := p.stopCh
|
||||||
doneCh := p.doneCh
|
doneCh := p.doneCh
|
||||||
|
crescStopCh := p.crescStopCh
|
||||||
|
crescDoneCh := p.crescDoneCh
|
||||||
p.stopCh = nil
|
p.stopCh = nil
|
||||||
p.doneCh = nil
|
p.doneCh = nil
|
||||||
|
p.crescStopCh = nil
|
||||||
|
p.crescDoneCh = nil
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Stop crescendo
|
||||||
|
if crescStopCh != nil {
|
||||||
|
close(crescStopCh)
|
||||||
|
}
|
||||||
|
if crescDoneCh != nil {
|
||||||
|
<-crescDoneCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop audio
|
||||||
if stopCh != nil {
|
if stopCh != nil {
|
||||||
close(stopCh)
|
close(stopCh)
|
||||||
}
|
}
|
||||||
|
|||||||
73
ui/config.go
73
ui/config.go
@@ -24,6 +24,11 @@ const (
|
|||||||
cfgServerPassword
|
cfgServerPassword
|
||||||
cfgAPIPassword
|
cfgAPIPassword
|
||||||
cfgPollSeconds
|
cfgPollSeconds
|
||||||
|
cfgCrescendoEnabled
|
||||||
|
cfgCrescendoStartPct
|
||||||
|
cfgCrescendoEndPct
|
||||||
|
cfgCrescendoDurationS
|
||||||
|
cfgCallbackScript
|
||||||
cfgFieldCount
|
cfgFieldCount
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +56,15 @@ func newConfigModel(cfg db.Settings) *configModel {
|
|||||||
c.fields[cfgServerPassword] = cfg.ServerPassword
|
c.fields[cfgServerPassword] = cfg.ServerPassword
|
||||||
c.fields[cfgAPIPassword] = cfg.APIPassword
|
c.fields[cfgAPIPassword] = cfg.APIPassword
|
||||||
c.fields[cfgPollSeconds] = strconv.Itoa(cfg.PollSeconds)
|
c.fields[cfgPollSeconds] = strconv.Itoa(cfg.PollSeconds)
|
||||||
|
if cfg.CrescendoEnabled {
|
||||||
|
c.fields[cfgCrescendoEnabled] = "true"
|
||||||
|
} else {
|
||||||
|
c.fields[cfgCrescendoEnabled] = "false"
|
||||||
|
}
|
||||||
|
c.fields[cfgCrescendoStartPct] = strconv.Itoa(cfg.CrescendoStartPct)
|
||||||
|
c.fields[cfgCrescendoEndPct] = strconv.Itoa(cfg.CrescendoEndPct)
|
||||||
|
c.fields[cfgCrescendoDurationS] = strconv.Itoa(cfg.CrescendoDurationS)
|
||||||
|
c.fields[cfgCallbackScript] = cfg.CallbackScript
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,11 +104,12 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case " ":
|
case " ":
|
||||||
// Toggle boolean fields
|
// Toggle boolean fields
|
||||||
if c.active == cfgShowSeconds {
|
if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled {
|
||||||
if c.fields[cfgShowSeconds] == "true" {
|
field := &c.fields[c.active]
|
||||||
c.fields[cfgShowSeconds] = "false"
|
if *field == "true" {
|
||||||
|
*field = "false"
|
||||||
} else {
|
} else {
|
||||||
c.fields[cfgShowSeconds] = "true"
|
*field = "true"
|
||||||
}
|
}
|
||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
@@ -106,7 +121,7 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) {
|
|||||||
default:
|
default:
|
||||||
if len(key) == 1 {
|
if len(key) == 1 {
|
||||||
// Don't allow free typing on boolean fields
|
// Don't allow free typing on boolean fields
|
||||||
if c.active == cfgShowSeconds {
|
if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled {
|
||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
c.fields[c.active] += key
|
c.fields[c.active] += key
|
||||||
@@ -159,6 +174,24 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) {
|
|||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
crescStartPct, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoStartPct]))
|
||||||
|
if err != nil || crescStartPct < 0 || crescStartPct > 100 {
|
||||||
|
c.err = "Crescendo start must be 0-100%"
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
crescEndPct, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoEndPct]))
|
||||||
|
if err != nil || crescEndPct < 0 || crescEndPct > 150 {
|
||||||
|
c.err = "Crescendo end must be 0-150%"
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
crescDuration, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoDurationS]))
|
||||||
|
if err != nil || crescDuration < 1 || crescDuration > 300 {
|
||||||
|
c.err = "Crescendo duration must be 1-300 seconds"
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
cfg := db.Settings{
|
cfg := db.Settings{
|
||||||
SnoozeMinutes: snooze,
|
SnoozeMinutes: snooze,
|
||||||
TimeoutMinutes: timeout,
|
TimeoutMinutes: timeout,
|
||||||
@@ -177,6 +210,12 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) {
|
|||||||
cfg.APIPassword = strings.TrimSpace(c.fields[cfgAPIPassword])
|
cfg.APIPassword = strings.TrimSpace(c.fields[cfgAPIPassword])
|
||||||
cfg.PollSeconds = pollSeconds
|
cfg.PollSeconds = pollSeconds
|
||||||
|
|
||||||
|
cfg.CrescendoEnabled = strings.TrimSpace(c.fields[cfgCrescendoEnabled]) == "true"
|
||||||
|
cfg.CrescendoStartPct = crescStartPct
|
||||||
|
cfg.CrescendoEndPct = crescEndPct
|
||||||
|
cfg.CrescendoDurationS = crescDuration
|
||||||
|
cfg.CallbackScript = strings.TrimSpace(c.fields[cfgCallbackScript])
|
||||||
|
|
||||||
if cfg.DefaultSound == "" {
|
if cfg.DefaultSound == "" {
|
||||||
cfg.DefaultSound = "default"
|
cfg.DefaultSound = "default"
|
||||||
}
|
}
|
||||||
@@ -208,6 +247,11 @@ func (c *configModel) View() string {
|
|||||||
"Server pass:",
|
"Server pass:",
|
||||||
"API password:",
|
"API password:",
|
||||||
"Poll (sec):",
|
"Poll (sec):",
|
||||||
|
"Enabled:",
|
||||||
|
"Start %:",
|
||||||
|
"End %:",
|
||||||
|
"Duration (s):",
|
||||||
|
"Script path:",
|
||||||
}
|
}
|
||||||
|
|
||||||
hints := [cfgFieldCount]string{
|
hints := [cfgFieldCount]string{
|
||||||
@@ -223,12 +267,31 @@ func (c *configModel) View() string {
|
|||||||
"password to auth with server",
|
"password to auth with server",
|
||||||
"password for incoming API requests",
|
"password for incoming API requests",
|
||||||
"1-60, client poll frequency",
|
"1-60, client poll frequency",
|
||||||
|
"space to toggle",
|
||||||
|
"0-100, starting volume",
|
||||||
|
"0-150, ending volume (>100 = overdrive)",
|
||||||
|
"1-300, ramp duration in seconds",
|
||||||
|
"called with: start|dismiss|snooze|timeout <name>",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section headers: field index -> section name
|
||||||
|
sections := map[cfgField]string{
|
||||||
|
cfgSnoozeMinutes: "─── Alarm ───",
|
||||||
|
cfgBlinkOnMs: "─── Display ───",
|
||||||
|
cfgServerURL: "─── Network ───",
|
||||||
|
cfgCrescendoEnabled: "─── Crescendo (pactl) ───",
|
||||||
|
cfgCallbackScript: "─── Hooks ───",
|
||||||
}
|
}
|
||||||
|
|
||||||
var lines []string
|
var lines []string
|
||||||
lines = append(lines, TitleStyle.Render("Settings"), "")
|
lines = append(lines, TitleStyle.Render("Settings"), "")
|
||||||
|
|
||||||
for i := cfgField(0); i < cfgFieldCount; i++ {
|
for i := cfgField(0); i < cfgFieldCount; i++ {
|
||||||
|
// Insert section header if this field starts a new section
|
||||||
|
if section, ok := sections[i]; ok {
|
||||||
|
lines = append(lines, "", DividerStyle.Render(section))
|
||||||
|
}
|
||||||
|
|
||||||
labelStr := fmt.Sprintf("%15s", labels[i])
|
labelStr := fmt.Sprintf("%15s", labels[i])
|
||||||
value := c.fields[i]
|
value := c.fields[i]
|
||||||
|
|
||||||
|
|||||||
29
ui/model.go
29
ui/model.go
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"woke/db"
|
"woke/db"
|
||||||
@@ -200,6 +201,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case autoTimeoutMsg:
|
case autoTimeoutMsg:
|
||||||
if m.firingAlarm != nil {
|
if m.firingAlarm != nil {
|
||||||
m.player.Stop()
|
m.player.Stop()
|
||||||
|
m.runCallback("timeout", m.firingAlarm.Name)
|
||||||
m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes)
|
m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes)
|
||||||
m.firingAlarm = nil
|
m.firingAlarm = nil
|
||||||
m.snoozeCount = 0
|
m.snoozeCount = 0
|
||||||
@@ -232,12 +234,14 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "enter", " ", "d":
|
case "enter", " ", "d":
|
||||||
m.player.Stop()
|
m.player.Stop()
|
||||||
alarm := m.firingAlarm
|
alarm := m.firingAlarm
|
||||||
|
m.runCallback("dismiss", alarm.Name)
|
||||||
m.firingAlarm = nil
|
m.firingAlarm = nil
|
||||||
m.snoozeCount = 0
|
m.snoozeCount = 0
|
||||||
m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name)
|
m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name)
|
||||||
case "s":
|
case "s":
|
||||||
m.player.Stop()
|
m.player.Stop()
|
||||||
alarm := *m.firingAlarm
|
alarm := *m.firingAlarm
|
||||||
|
m.runCallback("snooze", alarm.Name)
|
||||||
dur := m.snoozeDuration()
|
dur := m.snoozeDuration()
|
||||||
m.firingAlarm = nil
|
m.firingAlarm = nil
|
||||||
m.snoozeUntil = time.Now().Add(dur)
|
m.snoozeUntil = time.Now().Add(dur)
|
||||||
@@ -342,7 +346,30 @@ func (m *Model) startFiring(alarm *db.Alarm) {
|
|||||||
if sound == "default" || sound == "" {
|
if sound == "default" || sound == "" {
|
||||||
sound = m.settings.DefaultSound
|
sound = m.settings.DefaultSound
|
||||||
}
|
}
|
||||||
m.player.PlayLoop(sound)
|
|
||||||
|
// Use crescendo if enabled
|
||||||
|
cresc := player.CrescendoConfig{
|
||||||
|
Enabled: m.settings.CrescendoEnabled,
|
||||||
|
StartPct: m.settings.CrescendoStartPct,
|
||||||
|
EndPct: m.settings.CrescendoEndPct,
|
||||||
|
DurationS: m.settings.CrescendoDurationS,
|
||||||
|
}
|
||||||
|
m.player.PlayLoopWithCrescendo(sound, cresc)
|
||||||
|
|
||||||
|
// Fire callback
|
||||||
|
m.runCallback("start", alarm.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCallback executes the configured callback script with the event type and alarm name.
|
||||||
|
func (m *Model) runCallback(event, alarmName string) {
|
||||||
|
script := m.settings.CallbackScript
|
||||||
|
if script == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Run in background, don't block
|
||||||
|
go func() {
|
||||||
|
_ = exec.Command(script, event, alarmName).Run()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd {
|
func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd {
|
||||||
|
|||||||
Reference in New Issue
Block a user