diff --git a/player/player.go b/player/player.go index 832eeee..6f83f46 100644 --- a/player/player.go +++ b/player/player.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "sync" + "time" ) // Player handles alarm sound playback by shelling out to system audio tools. @@ -11,72 +12,129 @@ type Player struct { mu sync.Mutex cmd *exec.Cmd playing bool + stopCh chan struct{} } func New() *Player { return &Player{} } -// Play starts playing a sound file. If path is "default", it uses a system beep. -func (p *Player) Play(path string) { - p.mu.Lock() - defer p.mu.Unlock() - - p.stop() - - if path == "default" || path == "" { - // Try common system sounds, fall back to terminal bell - candidates := []string{ - "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", - "/usr/share/sounds/gnome/default/alerts/bark.ogg", - "/usr/share/sounds/sound-icons/trumpet-12.wav", +// resolveSound finds the actual file path for a sound. Returns "" if no file found. +func resolveSound(path string) string { + if path != "default" && path != "" { + if _, err := os.Stat(path); err == nil { + return path } - for _, c := range candidates { - if _, err := os.Stat(c); err == nil { - path = c - break - } - } - if path == "default" || path == "" { - // No sound files found — use terminal bell in a loop - go func() { - for i := 0; i < 10; i++ { - os.Stdout.WriteString("\a") - } - }() - return + return "" + } + candidates := []string{ + "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", + "/usr/share/sounds/gnome/default/alerts/bark.ogg", + "/usr/share/sounds/sound-icons/trumpet-12.wav", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c } } + return "" +} - // Try players in order of preference +// findPlayer returns the name and arg builder for the first available audio player. +func findPlayer() (string, func(string) []string) { players := []struct { name string args func(string) []string }{ {"paplay", func(f string) []string { return []string{f} }}, {"aplay", func(f string) []string { return []string{f} }}, - {"mpv", func(f string) []string { return []string{"--no-video", "--loop=inf", f} }}, - {"ffplay", func(f string) []string { return []string{"-nodisp", "-autoexit", "-loop", "0", f} }}, + {"mpv", func(f string) []string { return []string{"--no-video", "--no-terminal", f} }}, + {"ffplay", func(f string) []string { return []string{"-nodisp", "-autoexit", f} }}, } - for _, pl := range players { if _, err := exec.LookPath(pl.name); err == nil { - p.cmd = exec.Command(pl.name, pl.args(path)...) - p.cmd.Stdout = nil - p.cmd.Stderr = nil - p.playing = true - go func() { - _ = p.cmd.Run() - p.mu.Lock() - p.playing = false - p.mu.Unlock() - }() - return + return pl.name, pl.args } } + return "", nil +} - // Absolute fallback: terminal bell - os.Stdout.WriteString("\a") +// PlayLoop starts playing a sound file in a loop until Stop() is called. +func (p *Player) PlayLoop(path string) { + p.mu.Lock() + defer p.mu.Unlock() + + p.stop() + + resolved := resolveSound(path) + if resolved == "" { + // No sound file — bell loop + p.stopCh = make(chan struct{}) + p.playing = true + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + os.Stdout.WriteString("\a") + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + os.Stdout.WriteString("\a") + } + } + }() + return + } + + name, argsFn := findPlayer() + if name == "" { + os.Stdout.WriteString("\a") + return + } + + p.stopCh = make(chan struct{}) + p.playing = true + + go func() { + for { + select { + case <-p.stopCh: + return + default: + } + + cmd := exec.Command(name, argsFn(resolved)...) + cmd.Stdout = nil + cmd.Stderr = nil + + p.mu.Lock() + p.cmd = cmd + p.mu.Unlock() + + err := cmd.Run() + + p.mu.Lock() + p.cmd = nil + p.mu.Unlock() + + if err != nil { + // Check if we were told to stop + select { + case <-p.stopCh: + return + default: + } + } + + // Brief pause between loops to avoid hammering + select { + case <-p.stopCh: + return + case <-time.After(300 * time.Millisecond): + } + } + }() } // Stop stops any currently playing sound. @@ -87,12 +145,16 @@ func (p *Player) Stop() { } func (p *Player) stop() { - if p.cmd != nil && p.cmd.Process != nil && p.playing { + if p.stopCh != nil { + close(p.stopCh) + p.stopCh = nil + } + if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() _ = p.cmd.Wait() - p.playing = false p.cmd = nil } + p.playing = false } func (p *Player) IsPlaying() bool { diff --git a/ui/model.go b/ui/model.go index 641eb3f..6779c98 100644 --- a/ui/model.go +++ b/ui/model.go @@ -25,6 +25,8 @@ const ( type tickMsg time.Time type alarmFiredMsg scheduler.AlarmEvent type alarmsLoadedMsg []db.Alarm +type snoozeFireMsg db.Alarm +type autoTimeoutMsg struct{} // Model is the main bubbletea model. type Model struct { @@ -41,8 +43,10 @@ type Model struct { now time.Time // Alarm firing state - firingAlarm *db.Alarm - firingBlink bool + firingAlarm *db.Alarm + firingBlink bool + firingStart time.Time + snoozeCount int // Form state form *formModel @@ -113,11 +117,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case alarmFiredMsg: alarm := msg.Alarm - m.firingAlarm = &alarm - m.firingBlink = true - m.player.Play(alarm.SoundPath) + m.startFiring(&alarm) m.refreshAlarms() - return m, listenForAlarms(m.scheduler) + return m, tea.Batch( + listenForAlarms(m.scheduler), + autoTimeoutCmd(), + ) + + case snoozeFireMsg: + alarm := db.Alarm(msg) + m.snoozeCount++ + m.startFiring(&alarm) + return m, autoTimeoutCmd() + + case autoTimeoutMsg: + if m.firingAlarm != nil { + m.player.Stop() + m.statusMsg = "Alarm auto-dismissed after 5 minutes" + m.firingAlarm = nil + m.snoozeCount = 0 + } + return m, nil case tea.KeyMsg: return m.handleKey(msg) @@ -144,13 +164,16 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch key { case "enter", " ", "d": m.player.Stop() + alarm := m.firingAlarm m.firingAlarm = nil - m.statusMsg = "Alarm dismissed" + m.snoozeCount = 0 + m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name) case "s": m.player.Stop() + alarm := *m.firingAlarm m.firingAlarm = nil - m.statusMsg = "Snoozed for 5 minutes (not yet implemented)" - // TODO: implement snooze by creating a one-shot alarm 5min from now + m.statusMsg = fmt.Sprintf("Snoozed '%s' for 5 minutes", alarm.Name) + return m, snoozeCmd(alarm, snoozeDuration) } return m, nil } @@ -224,6 +247,30 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +const snoozeDuration = 5 * time.Minute +const autoTimeoutDuration = 5 * time.Minute + +func (m *Model) startFiring(alarm *db.Alarm) { + m.firingAlarm = alarm + m.firingBlink = true + m.firingStart = time.Now() + m.player.PlayLoop(alarm.SoundPath) +} + +func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd { + return func() tea.Msg { + time.Sleep(after) + return snoozeFireMsg(alarm) + } +} + +func autoTimeoutCmd() tea.Cmd { + return func() tea.Msg { + time.Sleep(autoTimeoutDuration) + return autoTimeoutMsg{} + } +} + func (m *Model) refreshAlarms() { alarms, err := m.store.ListAlarms() if err == nil { @@ -255,11 +302,14 @@ func (m Model) View() string { // Firing alarm overlay if m.firingAlarm != nil { - firingText := fmt.Sprintf("🔔 ALARM: %s 🔔", m.firingAlarm.Name) + firingText := fmt.Sprintf("ALARM: %s", m.firingAlarm.Name) if m.firingAlarm.Description != "" { firingText += "\n" + m.firingAlarm.Description } - firingText += "\n\n[Enter/Space/d] Dismiss [s] Snooze" + if m.snoozeCount > 0 { + firingText += fmt.Sprintf("\n(snoozed %d time(s))", m.snoozeCount) + } + firingText += "\n\n[Enter/Space/d] Dismiss [s] Snooze 5min" styled := AlarmFiringStyle.Render(firingText) styled = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, styled) sections = append(sections, "", styled) diff --git a/woke b/woke index 25d6630..dc2c1cb 100755 Binary files a/woke and b/woke differ