From bd2fc175a82be2a7bbd4e618230daa7f1d09a784 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Thu, 16 Jul 2026 18:03:23 +0300 Subject: [PATCH] feat(overlay): add gradual fade effect for aging caption lines Updated the overlay sink and state to apply a gradual fade from white to gray for completed caption lines over their display lifetime. The `FinalMarkup` method replaces plain `FinalText`, generating Pango markup with decreasing brightness. A periodic refresh timer (200 ms) recalculates colors and expiry, improving visual clarity of line recency. README updated to document the new fade behavior. --- README.md | 2 +- src/output/overlay/overlay_gtk.go | 36 ++++++++++++-------- src/output/overlay/state.go | 55 +++++++++++++++++++++++++++++-- src/output/overlay/state_test.go | 13 ++++++++ 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 78e0acb..19c4537 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Use `--output=both` to keep terminal output as well: ./scripts/distrobox/run.sh --mode=vad --output=both ``` -In VAD mode, a 500 ms pause finalizes the current caption and starts the next line. The overlay keeps up to six completed lines above the active provisional caption; each completed line remains visible for at least three seconds. If a seventh line arrives sooner, the hard six-line cap removes the oldest. Text is left-aligned inside the centered, fixed-width dark bubble. Pointer input passes through the window by default. Empty completed captions hide the overlay. +In VAD mode, a 500 ms pause finalizes the current caption and starts the next line. The overlay keeps up to six completed lines above the active provisional caption; each completed line remains visible for at least three seconds and gradually fades from white to a restrained gray as it ages. If a seventh line arrives sooner, the hard six-line cap removes the oldest. Text is left-aligned inside the centered, fixed-width dark bubble. Pointer input passes through the window by default. Empty completed captions hide the overlay. To build outside the helper script, install GTK4 and GTK4 Layer Shell development packages, then run: diff --git a/src/output/overlay/overlay_gtk.go b/src/output/overlay/overlay_gtk.go index b5b42f6..a748c12 100644 --- a/src/output/overlay/overlay_gtk.go +++ b/src/output/overlay/overlay_gtk.go @@ -215,7 +215,7 @@ static gboolean captioneer_apply_update(gpointer data) { CaptioneerOverlay *overlay = update->overlay; overlay->final_visible = update->final_text[0] != '\0'; - gtk_label_set_text(GTK_LABEL(overlay->final_label), update->final_text); + gtk_label_set_markup(GTK_LABEL(overlay->final_label), update->final_text); gtk_widget_set_visible(overlay->final_label, overlay->final_visible); overlay->preview_visible = update->preview_text[0] != '\0'; @@ -449,6 +449,8 @@ import ( "tea.chunkbyte.com/kato/captioneer/src/config" ) +const finalFadeRefreshInterval = 200 * time.Millisecond + type Sink struct { native *C.CaptioneerOverlay finalTimeout time.Duration @@ -500,29 +502,27 @@ func (s *Sink) Publish(event captions.Event) error { s.stopFinalTimer() } if event.Kind == captions.Final { - s.scheduleFinalExpiry(now) + s.scheduleFinalRefresh(now) } s.postState() return nil } -func (s *Sink) expireFinal() { +func (s *Sink) refreshFinals() { s.mu.Lock() defer s.mu.Unlock() if s.closed { return } now := time.Now() - changed := s.state.ExpireFinal(now) + s.state.ExpireFinal(now) s.finalTimer = nil - s.scheduleFinalExpiry(now) - if changed { - s.postState() - } + s.scheduleFinalRefresh(now) + s.postState() } func (s *Sink) postState() { - finalText := C.CString(s.state.FinalText()) + finalText := C.CString(s.state.FinalMarkup(time.Now(), s.finalTimeout)) previewText := C.CString(s.state.Provisional) defer C.free(unsafe.Pointer(finalText)) defer C.free(unsafe.Pointer(previewText)) @@ -536,16 +536,24 @@ func (s *Sink) stopFinalTimer() { } } -func (s *Sink) scheduleFinalExpiry(now time.Time) { - expiresAt := s.state.NextFinalExpiry() - if expiresAt.IsZero() { +func (s *Sink) scheduleFinalRefresh(now time.Time) { + if len(s.state.Finals) == 0 { return } - delay := expiresAt.Sub(now) + if s.state.NextFinalExpiry().IsZero() && !s.state.NeedsFadeRefresh(now, s.finalTimeout) { + return + } + + nextRefresh := now.Add(finalFadeRefreshInterval) + expiresAt := s.state.NextFinalExpiry() + if !expiresAt.IsZero() && expiresAt.Before(nextRefresh) { + nextRefresh = expiresAt + } + delay := nextRefresh.Sub(now) if delay < 0 { delay = 0 } - s.finalTimer = time.AfterFunc(delay, s.expireFinal) + s.finalTimer = time.AfterFunc(delay, s.refreshFinals) } func (s *Sink) Run() error { diff --git a/src/output/overlay/state.go b/src/output/overlay/state.go index 6740f1e..6ba4478 100644 --- a/src/output/overlay/state.go +++ b/src/output/overlay/state.go @@ -1,15 +1,22 @@ package overlay import ( + "fmt" + "html" "strings" "time" "tea.chunkbyte.com/kato/captioneer/src/captions" ) -const maxVisibleFinalLines = 6 +const ( + maxVisibleFinalLines = 6 + oldestLineBrightness = 0.65 + defaultLineFadeDuration = 3 * time.Second +) type finalLine struct { + CreatedAt time.Time Text string ExpiresAt time.Time } @@ -28,7 +35,7 @@ func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time if text == "" { return } - line := finalLine{Text: text} + line := finalLine{CreatedAt: now, Text: text} if finalTimeout > 0 { line.ExpiresAt = now.Add(finalTimeout) } @@ -66,9 +73,53 @@ func (s viewState) FinalText() string { return strings.Join(lines, "\n") } +func (s viewState) FinalMarkup(now time.Time, lineLifetime time.Duration) string { + fadeDuration := lineLifetime + if fadeDuration <= 0 { + fadeDuration = defaultLineFadeDuration + } + + var markup strings.Builder + for i, line := range s.Finals { + if i > 0 { + markup.WriteByte('\n') + } + brightness := lineBrightness(now.Sub(line.CreatedAt), fadeDuration) + channel := int(brightness*255 + 0.5) + fmt.Fprintf(&markup, ``, channel, channel, channel) + markup.WriteString(html.EscapeString(line.Text)) + markup.WriteString("") + } + return markup.String() +} + +func (s viewState) NeedsFadeRefresh(now time.Time, lineLifetime time.Duration) bool { + fadeDuration := lineLifetime + if fadeDuration <= 0 { + fadeDuration = defaultLineFadeDuration + } + for _, line := range s.Finals { + if now.Sub(line.CreatedAt) < fadeDuration { + return true + } + } + return false +} + func (s viewState) NextFinalExpiry() time.Time { if len(s.Finals) == 0 { return time.Time{} } return s.Finals[0].ExpiresAt } + +func lineBrightness(age, fadeDuration time.Duration) float64 { + if age <= 0 || fadeDuration <= 0 { + return 1 + } + progress := float64(age) / float64(fadeDuration) + if progress > 1 { + progress = 1 + } + return 1 - progress*(1-oldestLineBrightness) +} diff --git a/src/output/overlay/state_test.go b/src/output/overlay/state_test.go index 3178e51..3a5d884 100644 --- a/src/output/overlay/state_test.go +++ b/src/output/overlay/state_test.go @@ -61,3 +61,16 @@ func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) { t.Fatal("zero-timeout caption expired") } } + +func TestViewStateFinalMarkupFadesAndEscapesText(t *testing.T) { + now := time.Unix(100, 0) + var state viewState + state.Apply(captions.Event{Kind: captions.Final, Text: ""}, now, 3*time.Second) + + if got := state.FinalMarkup(now, 3*time.Second); got != `<hello & goodbye>` { + t.Fatalf("new line markup = %q", got) + } + if got := state.FinalMarkup(now.Add(3*time.Second), 3*time.Second); got != `<hello & goodbye>` { + t.Fatalf("old line markup = %q", got) + } +}