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:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"><hello & goodbye></span>` {
|
||||||
|
t.Fatalf("new line markup = %q", got)
|
||||||
|
}
|
||||||
|
if got := state.FinalMarkup(now.Add(3*time.Second), 3*time.Second); got != `<span foreground="#A6A6A6"><hello & goodbye></span>` {
|
||||||
|
t.Fatalf("old line markup = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user