Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package main

import (
"context"
_ "embed"
"fmt"
"log"
"os"
Expand All @@ -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"
Expand Down Expand Up @@ -69,6 +65,7 @@ type App struct {
consecutiveFailures int
mu sync.RWMutex
hideStaleIncoming bool
initialLoadComplete bool
}

func main() {
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")
}
}
}
Expand All @@ -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")
}
}
}
Expand All @@ -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.
Expand Down
Binary file added media/dark-impact-232945.wav
Binary file not shown.
Binary file added media/launch-85216.wav
Binary file not shown.
106 changes: 106 additions & 0 deletions sound.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}()
}
4 changes: 2 additions & 2 deletions ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down