Add on-screen clock overlay (outlined text, configurable corner/timezone)

A small always-on-top mpv window renders the local time in a screen
corner. Implementation notes:

- Transparent lavfi canvas (color=...@0.0, --alpha=yes) so the camera
  video shows through; no new dependencies. If the compositor can't do
  alpha it degrades to a dark backing, still legible.
- Time drawn as an ASS osd-overlay pushed over mpv IPC once a second:
  white fill + black outline (\bord), so it reads on both bright (day)
  and dark (night) scenes without sampling the picture. Formatting the
  text in Go avoids any filtergraph escaping.
- Time computed with time.LoadLocation against a configured IANA zone
  (default "Local"), so it's correct regardless of the host clock's zone
  and handles DST. A bad zone name fails at startup.
- Managed on the daemon's own context (survives layout switches); the
  daemon keeps it positioned and raised above camera tiles. ensureClock
  is a no-op when the clock config + resolution are unchanged, so a
  reload never disturbs it.

Config: new `clock` section (enabled, timezone, format, corner,
font_size, width, height, margin) with defaults and corner validation.
Documented in README and config.example (shipped enabled, America/Denver,
24-hour w/ seconds). Tests cover corner geometry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Woodard
2026-07-02 14:20:49 -05:00
parent 840324825b
commit caed091dc2
8 changed files with 419 additions and 1 deletions

View File

@@ -0,0 +1,29 @@
package daemon
import (
"testing"
"github.com/lwoodard/rtsp-streamer/internal/config"
)
func TestClockRectCorners(t *testing.T) {
cl := config.Clock{Width: 300, Height: 72, Margin: 24}
const w, h = 1920, 1080
cases := map[string][2]int{
"bottom-right": {1920 - 300 - 24, 1080 - 72 - 24},
"bottom-left": {24, 1080 - 72 - 24},
"top-right": {1920 - 300 - 24, 24},
"top-left": {24, 24},
"": {1920 - 300 - 24, 1080 - 72 - 24}, // default = bottom-right
}
for corner, want := range cases {
cl.Corner = corner
r := clockRect(w, h, cl)
if r.X != want[0] || r.Y != want[1] {
t.Errorf("corner %q: got (%d,%d), want (%d,%d)", corner, r.X, r.Y, want[0], want[1])
}
if r.W != 300 || r.H != 72 {
t.Errorf("corner %q: size (%d,%d), want (300,72)", corner, r.W, r.H)
}
}
}

View File

@@ -41,6 +41,10 @@ type Daemon struct {
cancelLo context.CancelFunc // cancels the current layout's supervisors
wg sync.WaitGroup
pcl *protect.Client // cached Protect session for view sync
clockSig string // config signature of the running clock
clockCancel context.CancelFunc // stops the clock supervisor
clockWG sync.WaitGroup
}
// New constructs a daemon bound to a config path. logs, when non-nil, is the
@@ -74,6 +78,7 @@ func (d *Daemon) Run(ctx context.Context) error {
<-ctx.Done()
d.log.Info("shutting down")
d.stopLayout()
d.stopClock()
return nil
}
@@ -98,6 +103,10 @@ func (d *Daemon) reload(ctx context.Context) error {
d.mu.Lock()
d.cfg = cfg
d.mu.Unlock()
// The clock overlay is independent of the layout, so manage it here on the
// daemon's own context — it survives layout switches instead of being torn
// down and rebuilt with each wall.
d.ensureClock(ctx, cfg)
name := cfg.ActiveLayout
if name == "" {
d.log.Warn("no active_layout set; nothing to display")
@@ -107,6 +116,81 @@ func (d *Daemon) reload(ctx context.Context) error {
return d.applyLayout(ctx, name)
}
// ensureClock starts, stops, or restarts the clock overlay to match cfg. It is
// a no-op when the clock config (and output resolution) is unchanged, so a
// routine reload never disturbs a running clock.
func (d *Daemon) ensureClock(ctx context.Context, cfg *config.Config) {
w, h := d.resolution(ctx, cfg)
sig := ""
if cfg.Clock.Enabled {
sig = fmt.Sprintf("%dx%d|%+v", w, h, cfg.Clock)
}
if sig == d.clockSig {
return
}
d.stopClock()
d.clockSig = sig
if !cfg.Clock.Enabled {
return
}
clock, err := player.NewClock(cfg.Clock, d.runDir, d.log)
if err != nil {
d.log.Warn("clock disabled", "err", err)
d.clockSig = ""
return
}
rect := clockRect(w, h, cfg.Clock)
clkCtx, cancel := context.WithCancel(ctx)
d.clockCancel = cancel
d.clockWG.Add(2)
go func() { defer d.clockWG.Done(); clock.Supervise(clkCtx) }()
go func() { defer d.clockWG.Done(); d.placeClock(clkCtx, clock, rect) }()
d.log.Info("clock overlay enabled", "corner", cfg.Clock.Corner, "rect", rect)
}
// stopClock tears down a running clock overlay, if any.
func (d *Daemon) stopClock() {
if d.clockCancel == nil {
return
}
d.clockCancel()
d.clockCancel = nil
d.clockWG.Wait()
}
// placeClock keeps the clock window in its corner and on top. It re-asserts on
// a slow tick because a layout switch remaps camera windows that would
// otherwise stack over the overlay.
func (d *Daemon) placeClock(ctx context.Context, clock *player.Clock, rect compositor.Rect) {
for {
if !sleepCtx(ctx, 2*time.Second) {
return
}
pid := clock.PID()
if pid == 0 {
continue
}
if err := d.comp.PlaceAndRaise(ctx, pid, rect); err != nil {
d.log.Debug("clock place failed", "err", err)
}
}
}
// clockRect computes the overlay rectangle for the configured corner.
func clockRect(w, h int, cl config.Clock) compositor.Rect {
m, cw, ch := cl.Margin, cl.Width, cl.Height
x, y := w-cw-m, h-ch-m // bottom-right default
switch cl.Corner {
case "bottom-left":
x, y = m, h-ch-m
case "top-left":
x, y = m, m
case "top-right":
x, y = w-cw-m, m
}
return compositor.Rect{X: x, Y: y, W: cw, H: ch}
}
// tileSpec is one resolved tile: which stream goes where. It doubles as the
// unit of the wall signature — if the specs (plus resolution and player
// settings) are unchanged, a reload need not touch the running streams.