diff --git a/main.go b/main.go index 9bd74fb..bf577b3 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,6 @@ package main import ( "context" - _ "embed" "fmt" "log" "os" @@ -20,9 +19,6 @@ import ( "github.com/ready-to-review/turnclient/pkg/turn" ) -//go:embed menubar-icon.png -var embeddedIcon []byte - // Version information - set during build with -ldflags. var ( version = "dev" @@ -69,6 +65,7 @@ type App struct { consecutiveFailures int mu sync.RWMutex hideStaleIncoming bool + initialLoadComplete bool } func main() { @@ -120,7 +117,6 @@ func main() { func (app *App) onReady(ctx context.Context) { log.Println("System tray ready") - systray.SetIcon(embeddedIcon) systray.SetTitle("Loading PRs...") systray.SetTooltip("GitHub PR Monitor") @@ -228,12 +224,14 @@ func (app *App) updatePRs(ctx context.Context) { if !app.hideStaleIncoming || !isStale(incoming[i].UpdatedAt) { incomingBlocked++ } - // Send notification for newly blocked - if !oldBlockedPRs[incoming[i].URL] { + // Send notification and play sound if PR wasn't blocked before + // (only after initial load to avoid startup noise) + if app.initialLoadComplete && !oldBlockedPRs[incoming[i].URL] { if err := beeep.Notify("PR Blocked on You", fmt.Sprintf("%s #%d: %s", incoming[i].Repository, incoming[i].Number, incoming[i].Title), ""); err != nil { log.Printf("Failed to send notification: %v", err) } + app.playSound(ctx, "detective") } } } @@ -244,12 +242,14 @@ func (app *App) updatePRs(ctx context.Context) { if !app.hideStaleIncoming || !isStale(outgoing[i].UpdatedAt) { outgoingBlocked++ } - // Send notification for newly blocked - if !oldBlockedPRs[outgoing[i].URL] { + // Send notification and play sound if PR wasn't blocked before + // (only after initial load to avoid startup noise) + if app.initialLoadComplete && !oldBlockedPRs[outgoing[i].URL] { if err := beeep.Notify("PR Blocked on You", fmt.Sprintf("%s #%d: %s", outgoing[i].Repository, outgoing[i].Number, outgoing[i].Title), ""); err != nil { log.Printf("Failed to send notification: %v", err) } + app.playSound(ctx, "rocket") } } } @@ -262,17 +262,25 @@ func (app *App) updatePRs(ctx context.Context) { app.mu.Unlock() // Set title based on PR state - systray.SetIcon(embeddedIcon) switch { case incomingBlocked == 0 && outgoingBlocked == 0: - systray.SetTitle("") + systray.SetTitle("😊") + case incomingBlocked > 0 && outgoingBlocked > 0: + systray.SetTitle(fmt.Sprintf("🕵️ %d / 🚀 %d", incomingBlocked, outgoingBlocked)) case incomingBlocked > 0: - systray.SetTitle(fmt.Sprintf("%d/%d 🔴", incomingBlocked, outgoingBlocked)) + systray.SetTitle(fmt.Sprintf("🕵️ %d", incomingBlocked)) default: - systray.SetTitle(fmt.Sprintf("0/%d 🚀", outgoingBlocked)) + systray.SetTitle(fmt.Sprintf("🚀 %d", outgoingBlocked)) } app.updateMenuIfChanged(ctx) + + // Mark initial load as complete after first successful update + if !app.initialLoadComplete { + app.mu.Lock() + app.initialLoadComplete = true + app.mu.Unlock() + } } // isStale returns true if the PR hasn't been updated in over 90 days. diff --git a/media/dark-impact-232945.wav b/media/dark-impact-232945.wav new file mode 100644 index 0000000..513963e Binary files /dev/null and b/media/dark-impact-232945.wav differ diff --git a/media/launch-85216.wav b/media/launch-85216.wav new file mode 100644 index 0000000..35bbfd9 Binary files /dev/null and b/media/launch-85216.wav differ diff --git a/sound.go b/sound.go new file mode 100644 index 0000000..d4c4a26 --- /dev/null +++ b/sound.go @@ -0,0 +1,106 @@ +// Package main - sound.go handles platform-specific sound playback. +package main + +import ( + "context" + _ "embed" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" +) + +//go:embed media/launch-85216.wav +var launchSound []byte + +//go:embed media/dark-impact-232945.wav +var impactSound []byte + +var soundCacheOnce sync.Once + +// initSoundCache writes embedded sounds to cache directory once. +func (app *App) initSoundCache() { + soundCacheOnce.Do(func() { + // Create sounds subdirectory in cache + soundDir := filepath.Join(app.cacheDir, "sounds") + if err := os.MkdirAll(soundDir, 0o700); err != nil { + log.Printf("Failed to create sound cache dir: %v", err) + return + } + + // Write launch sound + launchPath := filepath.Join(soundDir, "launch.wav") + if _, err := os.Stat(launchPath); os.IsNotExist(err) { + if err := os.WriteFile(launchPath, launchSound, 0o600); err != nil { + log.Printf("Failed to cache launch sound: %v", err) + } + } + + // Write impact sound + impactPath := filepath.Join(soundDir, "impact.wav") + if _, err := os.Stat(impactPath); os.IsNotExist(err) { + if err := os.WriteFile(impactPath, impactSound, 0o600); err != nil { + log.Printf("Failed to cache impact sound: %v", err) + } + } + }) +} + +// playSound plays a cached sound file using platform-specific commands. +func (app *App) playSound(ctx context.Context, soundType string) { + // Ensure sounds are cached + app.initSoundCache() + + // Select the sound file + var soundName string + switch soundType { + case "rocket": + soundName = "launch.wav" + case "detective": + soundName = "impact.wav" + default: + return + } + + soundPath := filepath.Join(app.cacheDir, "sounds", soundName) + + // Check if file exists + if _, err := os.Stat(soundPath); os.IsNotExist(err) { + log.Printf("Sound file not found in cache: %s", soundPath) + return + } + + // Play sound in background + go func() { + // Use a timeout context for sound playback + soundCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.CommandContext(soundCtx, "afplay", soundPath) + case "windows": + // Use PowerShell's SoundPlayer + script := `(New-Object Media.SoundPlayer "` + soundPath + `").PlaySync()` + cmd = exec.CommandContext(soundCtx, "powershell", "-WindowStyle", "Hidden", "-c", script) + case "linux": + // Try paplay first (PulseAudio), then aplay (ALSA) + cmd = exec.CommandContext(soundCtx, "paplay", soundPath) + if err := cmd.Run(); err != nil { + cmd = exec.CommandContext(soundCtx, "aplay", "-q", soundPath) + } + default: + return + } + + if cmd != nil { + if err := cmd.Run(); err != nil { + log.Printf("Failed to play sound: %v", err) + } + } + }() +} diff --git a/ui.go b/ui.go index 74d5fc4..e11b265 100644 --- a/ui.go +++ b/ui.go @@ -139,9 +139,9 @@ func (app *App) addPRMenuItem(ctx context.Context, pr PR, isOutgoing bool) { title := fmt.Sprintf("%s #%d", pr.Repository, pr.Number) if (!isOutgoing && pr.NeedsReview) || (isOutgoing && pr.IsBlocked) { if isOutgoing { - title = fmt.Sprintf("%s 🚀", title) + title = fmt.Sprintf("🚀 %s", title) } else { - title = fmt.Sprintf("%s 🔴", title) + title = fmt.Sprintf("🕵️ %s", title) } } tooltip := fmt.Sprintf("%s (%s)", pr.Title, formatAge(pr.UpdatedAt))