Skip to content

Commit 9f1e536

Browse files
committed
Improve UX & add sound
1 parent 97483c8 commit 9f1e536

File tree

5 files changed

+129
-15
lines changed

5 files changed

+129
-15
lines changed

main.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ package main
66

77
import (
88
"context"
9-
_ "embed"
109
"fmt"
1110
"log"
1211
"os"
@@ -20,9 +19,6 @@ import (
2019
"github.com/ready-to-review/turnclient/pkg/turn"
2120
)
2221

23-
//go:embed menubar-icon.png
24-
var embeddedIcon []byte
25-
2622
// Version information - set during build with -ldflags.
2723
var (
2824
version = "dev"
@@ -69,6 +65,7 @@ type App struct {
6965
consecutiveFailures int
7066
mu sync.RWMutex
7167
hideStaleIncoming bool
68+
initialLoadComplete bool
7269
}
7370

7471
func main() {
@@ -120,7 +117,6 @@ func main() {
120117

121118
func (app *App) onReady(ctx context.Context) {
122119
log.Println("System tray ready")
123-
systray.SetIcon(embeddedIcon)
124120
systray.SetTitle("Loading PRs...")
125121
systray.SetTooltip("GitHub PR Monitor")
126122

@@ -228,12 +224,14 @@ func (app *App) updatePRs(ctx context.Context) {
228224
if !app.hideStaleIncoming || !isStale(incoming[i].UpdatedAt) {
229225
incomingBlocked++
230226
}
231-
// Send notification for newly blocked
232-
if !oldBlockedPRs[incoming[i].URL] {
227+
// Send notification and play sound if PR wasn't blocked before
228+
// (only after initial load to avoid startup noise)
229+
if app.initialLoadComplete && !oldBlockedPRs[incoming[i].URL] {
233230
if err := beeep.Notify("PR Blocked on You",
234231
fmt.Sprintf("%s #%d: %s", incoming[i].Repository, incoming[i].Number, incoming[i].Title), ""); err != nil {
235232
log.Printf("Failed to send notification: %v", err)
236233
}
234+
app.playSound(ctx, "detective")
237235
}
238236
}
239237
}
@@ -244,12 +242,14 @@ func (app *App) updatePRs(ctx context.Context) {
244242
if !app.hideStaleIncoming || !isStale(outgoing[i].UpdatedAt) {
245243
outgoingBlocked++
246244
}
247-
// Send notification for newly blocked
248-
if !oldBlockedPRs[outgoing[i].URL] {
245+
// Send notification and play sound if PR wasn't blocked before
246+
// (only after initial load to avoid startup noise)
247+
if app.initialLoadComplete && !oldBlockedPRs[outgoing[i].URL] {
249248
if err := beeep.Notify("PR Blocked on You",
250249
fmt.Sprintf("%s #%d: %s", outgoing[i].Repository, outgoing[i].Number, outgoing[i].Title), ""); err != nil {
251250
log.Printf("Failed to send notification: %v", err)
252251
}
252+
app.playSound(ctx, "rocket")
253253
}
254254
}
255255
}
@@ -262,17 +262,25 @@ func (app *App) updatePRs(ctx context.Context) {
262262
app.mu.Unlock()
263263

264264
// Set title based on PR state
265-
systray.SetIcon(embeddedIcon)
266265
switch {
267266
case incomingBlocked == 0 && outgoingBlocked == 0:
268-
systray.SetTitle("")
267+
systray.SetTitle("😊")
268+
case incomingBlocked > 0 && outgoingBlocked > 0:
269+
systray.SetTitle(fmt.Sprintf("🕵️ %d / 🚀 %d", incomingBlocked, outgoingBlocked))
269270
case incomingBlocked > 0:
270-
systray.SetTitle(fmt.Sprintf("%d/%d 🔴", incomingBlocked, outgoingBlocked))
271+
systray.SetTitle(fmt.Sprintf("🕵️ %d", incomingBlocked))
271272
default:
272-
systray.SetTitle(fmt.Sprintf("0/%d 🚀", outgoingBlocked))
273+
systray.SetTitle(fmt.Sprintf("🚀 %d", outgoingBlocked))
273274
}
274275

275276
app.updateMenuIfChanged(ctx)
277+
278+
// Mark initial load as complete after first successful update
279+
if !app.initialLoadComplete {
280+
app.mu.Lock()
281+
app.initialLoadComplete = true
282+
app.mu.Unlock()
283+
}
276284
}
277285

278286
// isStale returns true if the PR hasn't been updated in over 90 days.

media/dark-impact-232945.wav

1.02 MB
Binary file not shown.

media/launch-85216.wav

410 KB
Binary file not shown.

sound.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Package main - sound.go handles platform-specific sound playback.
2+
package main
3+
4+
import (
5+
"context"
6+
_ "embed"
7+
"log"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"runtime"
12+
"sync"
13+
"time"
14+
)
15+
16+
//go:embed media/launch-85216.wav
17+
var launchSound []byte
18+
19+
//go:embed media/dark-impact-232945.wav
20+
var impactSound []byte
21+
22+
var soundCacheOnce sync.Once
23+
24+
// initSoundCache writes embedded sounds to cache directory once.
25+
func (app *App) initSoundCache() {
26+
soundCacheOnce.Do(func() {
27+
// Create sounds subdirectory in cache
28+
soundDir := filepath.Join(app.cacheDir, "sounds")
29+
if err := os.MkdirAll(soundDir, 0o700); err != nil {
30+
log.Printf("Failed to create sound cache dir: %v", err)
31+
return
32+
}
33+
34+
// Write launch sound
35+
launchPath := filepath.Join(soundDir, "launch.wav")
36+
if _, err := os.Stat(launchPath); os.IsNotExist(err) {
37+
if err := os.WriteFile(launchPath, launchSound, 0o600); err != nil {
38+
log.Printf("Failed to cache launch sound: %v", err)
39+
}
40+
}
41+
42+
// Write impact sound
43+
impactPath := filepath.Join(soundDir, "impact.wav")
44+
if _, err := os.Stat(impactPath); os.IsNotExist(err) {
45+
if err := os.WriteFile(impactPath, impactSound, 0o600); err != nil {
46+
log.Printf("Failed to cache impact sound: %v", err)
47+
}
48+
}
49+
})
50+
}
51+
52+
// playSound plays a cached sound file using platform-specific commands.
53+
func (app *App) playSound(ctx context.Context, soundType string) {
54+
// Ensure sounds are cached
55+
app.initSoundCache()
56+
57+
// Select the sound file
58+
var soundName string
59+
switch soundType {
60+
case "rocket":
61+
soundName = "launch.wav"
62+
case "detective":
63+
soundName = "impact.wav"
64+
default:
65+
return
66+
}
67+
68+
soundPath := filepath.Join(app.cacheDir, "sounds", soundName)
69+
70+
// Check if file exists
71+
if _, err := os.Stat(soundPath); os.IsNotExist(err) {
72+
log.Printf("Sound file not found in cache: %s", soundPath)
73+
return
74+
}
75+
76+
// Play sound in background
77+
go func() {
78+
// Use a timeout context for sound playback
79+
soundCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
80+
defer cancel()
81+
82+
var cmd *exec.Cmd
83+
switch runtime.GOOS {
84+
case "darwin":
85+
cmd = exec.CommandContext(soundCtx, "afplay", soundPath)
86+
case "windows":
87+
// Use PowerShell's SoundPlayer
88+
script := `(New-Object Media.SoundPlayer "` + soundPath + `").PlaySync()`
89+
cmd = exec.CommandContext(soundCtx, "powershell", "-WindowStyle", "Hidden", "-c", script)
90+
case "linux":
91+
// Try paplay first (PulseAudio), then aplay (ALSA)
92+
cmd = exec.CommandContext(soundCtx, "paplay", soundPath)
93+
if err := cmd.Run(); err != nil {
94+
cmd = exec.CommandContext(soundCtx, "aplay", "-q", soundPath)
95+
}
96+
default:
97+
return
98+
}
99+
100+
if cmd != nil {
101+
if err := cmd.Run(); err != nil {
102+
log.Printf("Failed to play sound: %v", err)
103+
}
104+
}
105+
}()
106+
}

ui.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,9 @@ func (app *App) addPRMenuItem(ctx context.Context, pr PR, isOutgoing bool) {
139139
title := fmt.Sprintf("%s #%d", pr.Repository, pr.Number)
140140
if (!isOutgoing && pr.NeedsReview) || (isOutgoing && pr.IsBlocked) {
141141
if isOutgoing {
142-
title = fmt.Sprintf("%s 🚀", title)
142+
title = fmt.Sprintf("🚀 %s", title)
143143
} else {
144-
title = fmt.Sprintf("%s 🔴", title)
144+
title = fmt.Sprintf("🕵️ %s", title)
145145
}
146146
}
147147
tooltip := fmt.Sprintf("%s (%s)", pr.Title, formatAge(pr.UpdatedAt))

0 commit comments

Comments
 (0)