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)
+ }
+}