feat(notify): desktop notification per announcement with a Cancel button
Every playing announcement now shows a desktop notification with its text and, on Linux, a Cancel button (Abbrechen for German) that stops exactly that announcement; the notification is closed when the announcement ends. This brings back what the old talk.sh had with notify-send --action, as a default: a long announcement can be cut short without a terminal. Implementation: the freedesktop Notifications service on the session bus via godbus (pure Go): one connection per process, Notify with the action list, ActionInvoked dispatched to the announcement, CloseNotification at the end, expire_timeout 0 so the button stays for the whole announcement. Without a session bus the notify-send fallback shows a plain notification; macOS and Windows show one without a button for now. queue.CancelItem(id) cancels only the playing item. Config [notify] on_speak (default true, env TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a click; `talk doctor` names the service and whether it supports the button. Config hygiene found on the way: a key written below a [table] header belongs to that table in TOML (lang under [voice] was silently ignored). config.Load now collects unknown keys (undecoded ones and non-language keys under [voice.<provider>]) and `talk doctor` warns with the hint. Tests: process test on a private dbus-daemon with a fake org.freedesktop.Notifications service (Notify payload, cancel click aborts the synthesis, CloseNotification), queue.CancelItem, config keys. All test binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever pops a notification on the developer's desktop. flake vendorHash updated for the new dependency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f3e7c9f35
commit
83a15af9c5
22 files changed
+806
-45
No files matched your search
@@ -84,7 +84,34 @@ func Run(ctx context.Context, opts Options) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := queue.New(queue.Laws{
|
||||
// Desktop notification per announcement (notify.on_speak): shown when the
|
||||
// announcement starts, with a Cancel button where the platform has one,
|
||||
// closed when it ends. Best effort: the first failure is logged, later
|
||||
// ones are silent, speech never waits for a notification problem.
|
||||
var q *queue.Queue
|
||||
var notifyFailed sync.Once
|
||||
announceNotification := func(ctx context.Context, item queue.Item, lang string) func() {
|
||||
if !cfg.Notify.OnSpeak {
|
||||
return func() {}
|
||||
}
|
||||
label := "Cancel"
|
||||
if lang == "de" {
|
||||
label = "Abbrechen"
|
||||
}
|
||||
closeFn, err := notify.Announce(ctx, item.Text, label, func() {
|
||||
if q.CancelItem(item.ID) {
|
||||
logger.Printf("announcement %s cancelled from its notification", item.ID)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
notifyFailed.Do(func() {
|
||||
logger.Printf("warning: notifications unavailable (%v); announcements play without one. Set [notify] on_speak = false to silence this", err)
|
||||
})
|
||||
}
|
||||
return closeFn
|
||||
}
|
||||
|
||||
q = queue.New(queue.Laws{
|
||||
RateLimit: cfg.Queue.RateLimit,
|
||||
AllowHighBurst: cfg.Queue.AllowHighBurst,
|
||||
Quiet: quiet,
|
||||
@@ -92,12 +119,10 @@ func Run(ctx context.Context, opts Options) error {
|
||||
MaxText: cfg.Queue.MaxText,
|
||||
MaxAge: cfg.Queue.MaxAge,
|
||||
}, func(ctx context.Context, item queue.Item) error {
|
||||
out, err := engine.Speak(ctx, speak.Request{
|
||||
Text: item.Text,
|
||||
Lang: item.Lang,
|
||||
Provider: item.Provider,
|
||||
Voice: item.Voice,
|
||||
})
|
||||
req := speak.Request{Text: item.Text, Lang: item.Lang, Provider: item.Provider, Voice: item.Voice}
|
||||
closeNotification := announceNotification(ctx, item, engine.Lang(req))
|
||||
defer closeNotification()
|
||||
out, err := engine.Speak(ctx, req)
|
||||
if err == nil && !out.Discarded {
|
||||
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
|
||||
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond))
|
||||
@@ -175,8 +200,8 @@ func Run(ctx context.Context, opts Options) error {
|
||||
if len(cfg.Server.AllowedOrigins) > 0 {
|
||||
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
|
||||
}
|
||||
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
|
||||
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(),
|
||||
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, notify=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
|
||||
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(), cfg.Notify.OnSpeak,
|
||||
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
|
||||
|
||||
select {
|
||||
|
||||
Reference in new issue
Block a user