Compare commits
2
Commits
fea9161b3c
...
bd2fc175a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd2fc175a8 | ||
|
|
55181d6397 |
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
The overlay shows the latest final caption above the active provisional caption. Final captions remain for four seconds by default. The dark background is 90% opaque, text is centered and limited to two lines per row, and 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:
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ Layer Shell and X11 hints are requests, not universal guarantees. Same-layer win
|
|||||||
| `--overlay-bottom-margin=100` | `100` | Bottom margin in logical pixels. |
|
| `--overlay-bottom-margin=100` | `100` | Bottom margin in logical pixels. |
|
||||||
| `--overlay-max-width=1200` | `1200` | Fixed caption-bubble width; it is capped at 80% of the selected monitor. |
|
| `--overlay-max-width=1200` | `1200` | Fixed caption-bubble width; it is capped at 80% of the selected monitor. |
|
||||||
| `--overlay-monitor=auto` | `auto` | `auto`, a zero-based monitor index, or a connector name such as `DP-1`. |
|
| `--overlay-monitor=auto` | `auto` | `auto`, a zero-based monitor index, or a connector name such as `DP-1`. |
|
||||||
| `--overlay-final-timeout=4s` | `4s` | Time before the final row disappears. `0s` keeps it until replaced or hidden. |
|
| `--overlay-final-timeout=3s` | `3s` | Completed-caption lifetime; it must be `0s` or at least `3s`. `0s` keeps lines until the six-line cap removes them. |
|
||||||
| `--overlay-click-through=true` | `true` | Use an empty input region where the display backend supports it. |
|
| `--overlay-click-through=true` | `true` | Use an empty input region where the display backend supports it. |
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -202,4 +202,4 @@ Run this from the repository root. The build and run helpers resolve that path a
|
|||||||
|
|
||||||
Captioneer is licensed under the [Apache License 2.0](LICENSE). Sherpa-Onnx is Apache-2.0 licensed, GTK4 is LGPL-2.1-or-later, and GTK4 Layer Shell is MIT licensed. Captioneer links to the system GTK libraries rather than copying their source into the project.
|
Captioneer is licensed under the [Apache License 2.0](LICENSE). Sherpa-Onnx is Apache-2.0 licensed, GTK4 is LGPL-2.1-or-later, and GTK4 Layer Shell is MIT licensed. Captioneer links to the system GTK libraries rather than copying their source into the project.
|
||||||
|
|
||||||
Version 1 intentionally has no animation, caption history, runtime dragging, background-free mode, capture exclusion, Flatpak, desktop entry, or autostart integration.
|
Version 1 intentionally has no animation, persisted caption history, runtime dragging, background-free mode, capture exclusion, Flatpak, desktop entry, or autostart integration.
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultModelsDir = "models"
|
const (
|
||||||
|
DefaultModelsDir = "models"
|
||||||
|
MinimumOverlayFinalLifetime = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type Mode string
|
type Mode string
|
||||||
|
|
||||||
@@ -88,7 +91,7 @@ func ParseDesktop() DesktopSettings {
|
|||||||
flags.IntVar(&desktop.Overlay.BottomMargin, "overlay-bottom-margin", 100, "distance from the monitor bottom in logical pixels")
|
flags.IntVar(&desktop.Overlay.BottomMargin, "overlay-bottom-margin", 100, "distance from the monitor bottom in logical pixels")
|
||||||
flags.IntVar(&desktop.Overlay.MaxWidth, "overlay-max-width", 1200, "fixed caption width in logical pixels before the monitor safety cap")
|
flags.IntVar(&desktop.Overlay.MaxWidth, "overlay-max-width", 1200, "fixed caption width in logical pixels before the monitor safety cap")
|
||||||
flags.StringVar(&desktop.Overlay.Monitor, "overlay-monitor", "auto", "monitor selection: auto, numeric index, or connector name")
|
flags.StringVar(&desktop.Overlay.Monitor, "overlay-monitor", "auto", "monitor selection: auto, numeric index, or connector name")
|
||||||
flags.DurationVar(&desktop.Overlay.FinalTimeout, "overlay-final-timeout", 4*time.Second, "how long a final caption remains; zero waits for replacement")
|
flags.DurationVar(&desktop.Overlay.FinalTimeout, "overlay-final-timeout", MinimumOverlayFinalLifetime, "completed-caption lifetime; zero keeps lines until the six-line cap removes them")
|
||||||
flags.BoolVar(&desktop.Overlay.ClickThrough, "overlay-click-through", true, "pass pointer input through the caption window")
|
flags.BoolVar(&desktop.Overlay.ClickThrough, "overlay-click-through", true, "pass pointer input through the caption window")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
desktop.Mode = Mode(mode)
|
desktop.Mode = Mode(mode)
|
||||||
@@ -135,6 +138,9 @@ func (s DesktopSettings) Validate() error {
|
|||||||
if s.Overlay.FinalTimeout < 0 {
|
if s.Overlay.FinalTimeout < 0 {
|
||||||
return errors.New("--overlay-final-timeout cannot be negative")
|
return errors.New("--overlay-final-timeout cannot be negative")
|
||||||
}
|
}
|
||||||
|
if s.Overlay.FinalTimeout > 0 && s.Overlay.FinalTimeout < MinimumOverlayFinalLifetime {
|
||||||
|
return errors.New("--overlay-final-timeout must be zero or at least 3s")
|
||||||
|
}
|
||||||
if s.Overlay.Monitor == "" {
|
if s.Overlay.Monitor == "" {
|
||||||
return errors.New("--overlay-monitor cannot be empty")
|
return errors.New("--overlay-monitor cannot be empty")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func TestDesktopSettingsValidate(t *testing.T) {
|
|||||||
{"zero width", func(s *DesktopSettings) { s.Overlay.MaxWidth = 0 }},
|
{"zero width", func(s *DesktopSettings) { s.Overlay.MaxWidth = 0 }},
|
||||||
{"empty monitor", func(s *DesktopSettings) { s.Overlay.Monitor = "" }},
|
{"empty monitor", func(s *DesktopSettings) { s.Overlay.Monitor = "" }},
|
||||||
{"negative timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = -time.Second }},
|
{"negative timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = -time.Second }},
|
||||||
|
{"short timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = time.Second }},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ func newVAD(model string, threads int) *sherpa.VoiceActivityDetector {
|
|||||||
SileroVad: sherpa.SileroVadModelConfig{
|
SileroVad: sherpa.SileroVadModelConfig{
|
||||||
Model: model,
|
Model: model,
|
||||||
Threshold: 0.5,
|
Threshold: 0.5,
|
||||||
|
// A 500 ms pause ends the current caption line and starts the next one.
|
||||||
MinSilenceDuration: 0.5,
|
MinSilenceDuration: 0.5,
|
||||||
MinSpeechDuration: 0.25,
|
MinSpeechDuration: 0.25,
|
||||||
WindowSize: 512,
|
WindowSize: 512,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -354,13 +354,17 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
|||||||
gtk_label_set_xalign(GTK_LABEL(labels[i]), 0.0f);
|
gtk_label_set_xalign(GTK_LABEL(labels[i]), 0.0f);
|
||||||
gtk_label_set_wrap(GTK_LABEL(labels[i]), TRUE);
|
gtk_label_set_wrap(GTK_LABEL(labels[i]), TRUE);
|
||||||
gtk_label_set_wrap_mode(GTK_LABEL(labels[i]), PANGO_WRAP_WORD_CHAR);
|
gtk_label_set_wrap_mode(GTK_LABEL(labels[i]), PANGO_WRAP_WORD_CHAR);
|
||||||
gtk_label_set_lines(GTK_LABEL(labels[i]), 2);
|
|
||||||
gtk_label_set_ellipsize(GTK_LABEL(labels[i]), PANGO_ELLIPSIZE_END);
|
|
||||||
gtk_label_set_max_width_chars(GTK_LABEL(labels[i]), max_chars);
|
gtk_label_set_max_width_chars(GTK_LABEL(labels[i]), max_chars);
|
||||||
gtk_widget_set_halign(labels[i], GTK_ALIGN_FILL);
|
gtk_widget_set_halign(labels[i], GTK_ALIGN_FILL);
|
||||||
gtk_box_append(GTK_BOX(box), labels[i]);
|
gtk_box_append(GTK_BOX(box), labels[i]);
|
||||||
gtk_widget_set_visible(labels[i], FALSE);
|
gtk_widget_set_visible(labels[i], FALSE);
|
||||||
}
|
}
|
||||||
|
// Completed-caption history grows vertically for every visible entry. The
|
||||||
|
// view state, rather than GTK ellipsizing, enforces its six-entry limit.
|
||||||
|
gtk_label_set_lines(GTK_LABEL(overlay->final_label), -1);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(overlay->final_label), PANGO_ELLIPSIZE_NONE);
|
||||||
|
gtk_label_set_lines(GTK_LABEL(overlay->preview_label), 2);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(overlay->preview_label), PANGO_ELLIPSIZE_END);
|
||||||
gtk_widget_add_css_class(overlay->final_label, "captioneer-final");
|
gtk_widget_add_css_class(overlay->final_label, "captioneer-final");
|
||||||
gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview");
|
gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview");
|
||||||
gtk_window_set_child(overlay->window, box);
|
gtk_window_set_child(overlay->window, box);
|
||||||
@@ -445,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
|
||||||
@@ -490,29 +496,33 @@ func (s *Sink) Publish(event captions.Event) error {
|
|||||||
if s.closed {
|
if s.closed {
|
||||||
return errors.New("overlay is closed")
|
return errors.New("overlay is closed")
|
||||||
}
|
}
|
||||||
s.state.Apply(event, time.Now(), s.finalTimeout)
|
now := time.Now()
|
||||||
|
s.state.Apply(event, now, s.finalTimeout)
|
||||||
if event.Kind == captions.Final || event.Kind == captions.Hide {
|
if event.Kind == captions.Final || event.Kind == captions.Hide {
|
||||||
s.stopFinalTimer()
|
s.stopFinalTimer()
|
||||||
}
|
}
|
||||||
if event.Kind == captions.Final && s.finalTimeout > 0 {
|
if event.Kind == captions.Final {
|
||||||
s.finalTimer = time.AfterFunc(s.finalTimeout, s.expireFinal)
|
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 || !s.state.ExpireFinal(time.Now()) {
|
if s.closed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
s.state.ExpireFinal(now)
|
||||||
s.finalTimer = nil
|
s.finalTimer = nil
|
||||||
|
s.scheduleFinalRefresh(now)
|
||||||
s.postState()
|
s.postState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sink) postState() {
|
func (s *Sink) postState() {
|
||||||
finalText := C.CString(s.state.Final)
|
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))
|
||||||
@@ -526,6 +536,26 @@ func (s *Sink) stopFinalTimer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Sink) scheduleFinalRefresh(now time.Time) {
|
||||||
|
if len(s.state.Finals) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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.refreshFinals)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Sink) Run() error {
|
func (s *Sink) Run() error {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
if s.closed {
|
if s.closed {
|
||||||
|
|||||||
+96
-10
@@ -1,16 +1,29 @@
|
|||||||
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
|
||||||
|
oldestLineBrightness = 0.65
|
||||||
|
defaultLineFadeDuration = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type finalLine struct {
|
||||||
|
CreatedAt time.Time
|
||||||
|
Text string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type viewState struct {
|
type viewState struct {
|
||||||
Final string
|
Finals []finalLine
|
||||||
Provisional string
|
Provisional string
|
||||||
FinalExpiresAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration) {
|
func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration) {
|
||||||
@@ -18,22 +31,95 @@ func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time
|
|||||||
case captions.Provisional:
|
case captions.Provisional:
|
||||||
s.Provisional = strings.TrimSpace(event.Text)
|
s.Provisional = strings.TrimSpace(event.Text)
|
||||||
case captions.Final:
|
case captions.Final:
|
||||||
s.Final = strings.TrimSpace(event.Text)
|
text := strings.TrimSpace(event.Text)
|
||||||
s.Provisional = ""
|
if text == "" {
|
||||||
s.FinalExpiresAt = time.Time{}
|
return
|
||||||
if finalTimeout > 0 {
|
|
||||||
s.FinalExpiresAt = now.Add(finalTimeout)
|
|
||||||
}
|
}
|
||||||
|
line := finalLine{CreatedAt: now, Text: text}
|
||||||
|
if finalTimeout > 0 {
|
||||||
|
line.ExpiresAt = now.Add(finalTimeout)
|
||||||
|
}
|
||||||
|
s.Finals = append(s.Finals, line)
|
||||||
|
if len(s.Finals) > maxVisibleFinalLines {
|
||||||
|
s.Finals = s.Finals[len(s.Finals)-maxVisibleFinalLines:]
|
||||||
|
}
|
||||||
|
s.Provisional = ""
|
||||||
case captions.Hide:
|
case captions.Hide:
|
||||||
*s = viewState{}
|
*s = viewState{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *viewState) ExpireFinal(now time.Time) bool {
|
func (s *viewState) ExpireFinal(now time.Time) bool {
|
||||||
if s.FinalExpiresAt.IsZero() || now.Before(s.FinalExpiresAt) {
|
firstVisible := 0
|
||||||
|
for firstVisible < len(s.Finals) {
|
||||||
|
expiresAt := s.Finals[firstVisible].ExpiresAt
|
||||||
|
if expiresAt.IsZero() || now.Before(expiresAt) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
firstVisible++
|
||||||
|
}
|
||||||
|
if firstVisible == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
s.Final = ""
|
s.Finals = s.Finals[firstVisible:]
|
||||||
s.FinalExpiresAt = time.Time{}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s viewState) FinalText() string {
|
||||||
|
lines := make([]string, len(s.Finals))
|
||||||
|
for i, line := range s.Finals {
|
||||||
|
lines[i] = line.Text
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,39 +7,52 @@ import (
|
|||||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestViewStateKeepsFinalAboveNewProvisional(t *testing.T) {
|
func TestViewStateKeepsCompletedLinesAboveNewProvisional(t *testing.T) {
|
||||||
now := time.Unix(100, 0)
|
now := time.Unix(100, 0)
|
||||||
var state viewState
|
var state viewState
|
||||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: " first draft "}, now, 4*time.Second)
|
state.Apply(captions.Event{Kind: captions.Provisional, Text: " first draft "}, now, 4*time.Second)
|
||||||
state.Apply(captions.Event{Kind: captions.Final, Text: " final "}, now, 4*time.Second)
|
state.Apply(captions.Event{Kind: captions.Final, Text: " first final "}, now, 4*time.Second)
|
||||||
|
state.Apply(captions.Event{Kind: captions.Final, Text: " second final "}, now, 4*time.Second)
|
||||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: " next draft "}, now, 4*time.Second)
|
state.Apply(captions.Event{Kind: captions.Provisional, Text: " next draft "}, now, 4*time.Second)
|
||||||
|
|
||||||
if state.Final != "final" || state.Provisional != "next draft" {
|
if state.FinalText() != "first final\nsecond final" || state.Provisional != "next draft" {
|
||||||
t.Fatalf("unexpected rows: %#v", state)
|
t.Fatalf("unexpected rows: %#v", state)
|
||||||
}
|
}
|
||||||
state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second)
|
state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second)
|
||||||
if state.Final != "" || state.Provisional != "" {
|
if state.FinalText() != "" || state.Provisional != "" {
|
||||||
t.Fatalf("hide did not clear rows: %#v", state)
|
t.Fatalf("hide did not clear rows: %#v", state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestViewStateFinalTimeout(t *testing.T) {
|
func TestViewStateExpiresEachCompletedLineAfterItsOwnLifetime(t *testing.T) {
|
||||||
now := time.Unix(100, 0)
|
now := time.Unix(100, 0)
|
||||||
var state viewState
|
var state viewState
|
||||||
state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 4*time.Second)
|
state.Apply(captions.Event{Kind: captions.Final, Text: "first"}, now, 3*time.Second)
|
||||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 4*time.Second)
|
state.Apply(captions.Event{Kind: captions.Final, Text: "second"}, now.Add(time.Second), 3*time.Second)
|
||||||
|
state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 3*time.Second)
|
||||||
|
|
||||||
if state.ExpireFinal(now.Add(3999 * time.Millisecond)) {
|
if state.ExpireFinal(now.Add(2999 * time.Millisecond)) {
|
||||||
t.Fatal("final caption expired early")
|
t.Fatal("completed line expired early")
|
||||||
}
|
}
|
||||||
if !state.ExpireFinal(now.Add(4 * time.Second)) {
|
if !state.ExpireFinal(now.Add(3 * time.Second)) {
|
||||||
t.Fatal("final caption did not expire")
|
t.Fatal("first completed line did not expire")
|
||||||
}
|
}
|
||||||
if state.Final != "" || state.Provisional != "draft" {
|
if state.FinalText() != "second" || state.Provisional != "draft" {
|
||||||
t.Fatalf("expiry changed the wrong row: %#v", state)
|
t.Fatalf("expiry changed the wrong row: %#v", state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestViewStateLimitsVisibleCompletedLines(t *testing.T) {
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
var state viewState
|
||||||
|
for i := 0; i <= maxVisibleFinalLines; i++ {
|
||||||
|
state.Apply(captions.Event{Kind: captions.Final, Text: string(rune('a' + i))}, now, 3*time.Second)
|
||||||
|
}
|
||||||
|
if len(state.Finals) != maxVisibleFinalLines || state.FinalText() != "b\nc\nd\ne\nf\ng" {
|
||||||
|
t.Fatalf("unexpected completed-line limit: %#v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
|
func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
|
||||||
now := time.Unix(100, 0)
|
now := time.Unix(100, 0)
|
||||||
var state viewState
|
var state viewState
|
||||||
@@ -48,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