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.
This commit is contained in:
2026-07-16 18:03:23 +03:00
parent 55181d6397
commit bd2fc175a8
4 changed files with 89 additions and 17 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ Use `--output=both` to keep terminal output as well:
./scripts/distrobox/run.sh --mode=vad --output=both ./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: To build outside the helper script, install GTK4 and GTK4 Layer Shell development packages, then run:
+21 -13
View File
@@ -215,7 +215,7 @@ static gboolean captioneer_apply_update(gpointer data) {
CaptioneerOverlay *overlay = update->overlay; CaptioneerOverlay *overlay = update->overlay;
overlay->final_visible = update->final_text[0] != '\0'; 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); gtk_widget_set_visible(overlay->final_label, overlay->final_visible);
overlay->preview_visible = update->preview_text[0] != '\0'; overlay->preview_visible = update->preview_text[0] != '\0';
@@ -449,6 +449,8 @@ import (
"tea.chunkbyte.com/kato/captioneer/src/config" "tea.chunkbyte.com/kato/captioneer/src/config"
) )
const finalFadeRefreshInterval = 200 * time.Millisecond
type Sink struct { type Sink struct {
native *C.CaptioneerOverlay native *C.CaptioneerOverlay
finalTimeout time.Duration finalTimeout time.Duration
@@ -500,29 +502,27 @@ func (s *Sink) Publish(event captions.Event) error {
s.stopFinalTimer() s.stopFinalTimer()
} }
if event.Kind == captions.Final { if event.Kind == captions.Final {
s.scheduleFinalExpiry(now) s.scheduleFinalRefresh(now)
} }
s.postState() s.postState()
return nil return nil
} }
func (s *Sink) expireFinal() { func (s *Sink) refreshFinals() {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.closed { if s.closed {
return return
} }
now := time.Now() now := time.Now()
changed := s.state.ExpireFinal(now) s.state.ExpireFinal(now)
s.finalTimer = nil s.finalTimer = nil
s.scheduleFinalExpiry(now) s.scheduleFinalRefresh(now)
if changed {
s.postState() s.postState()
}
} }
func (s *Sink) 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) previewText := C.CString(s.state.Provisional)
defer C.free(unsafe.Pointer(finalText)) defer C.free(unsafe.Pointer(finalText))
defer C.free(unsafe.Pointer(previewText)) defer C.free(unsafe.Pointer(previewText))
@@ -536,16 +536,24 @@ func (s *Sink) stopFinalTimer() {
} }
} }
func (s *Sink) scheduleFinalExpiry(now time.Time) { func (s *Sink) scheduleFinalRefresh(now time.Time) {
expiresAt := s.state.NextFinalExpiry() if len(s.state.Finals) == 0 {
if expiresAt.IsZero() {
return 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 { if delay < 0 {
delay = 0 delay = 0
} }
s.finalTimer = time.AfterFunc(delay, s.expireFinal) s.finalTimer = time.AfterFunc(delay, s.refreshFinals)
} }
func (s *Sink) Run() error { func (s *Sink) Run() error {
+53 -2
View File
@@ -1,15 +1,22 @@
package overlay package overlay
import ( import (
"fmt"
"html"
"strings" "strings"
"time" "time"
"tea.chunkbyte.com/kato/captioneer/src/captions" "tea.chunkbyte.com/kato/captioneer/src/captions"
) )
const maxVisibleFinalLines = 6 const (
maxVisibleFinalLines = 6
oldestLineBrightness = 0.65
defaultLineFadeDuration = 3 * time.Second
)
type finalLine struct { type finalLine struct {
CreatedAt time.Time
Text string Text string
ExpiresAt time.Time ExpiresAt time.Time
} }
@@ -28,7 +35,7 @@ func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time
if text == "" { if text == "" {
return return
} }
line := finalLine{Text: text} line := finalLine{CreatedAt: now, Text: text}
if finalTimeout > 0 { if finalTimeout > 0 {
line.ExpiresAt = now.Add(finalTimeout) line.ExpiresAt = now.Add(finalTimeout)
} }
@@ -66,9 +73,53 @@ func (s viewState) FinalText() string {
return strings.Join(lines, "\n") 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, `<span foreground="#%02X%02X%02X">`, channel, channel, channel)
markup.WriteString(html.EscapeString(line.Text))
markup.WriteString("</span>")
}
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 { func (s viewState) NextFinalExpiry() time.Time {
if len(s.Finals) == 0 { if len(s.Finals) == 0 {
return time.Time{} return time.Time{}
} }
return s.Finals[0].ExpiresAt 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)
}
+13
View File
@@ -61,3 +61,16 @@ func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
t.Fatal("zero-timeout caption expired") 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: "<hello & goodbye>"}, now, 3*time.Second)
if got := state.FinalMarkup(now, 3*time.Second); got != `<span foreground="#FFFFFF">&lt;hello &amp; goodbye&gt;</span>` {
t.Fatalf("new line markup = %q", got)
}
if got := state.FinalMarkup(now.Add(3*time.Second), 3*time.Second); got != `<span foreground="#A6A6A6">&lt;hello &amp; goodbye&gt;</span>` {
t.Fatalf("old line markup = %q", got)
}
}