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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user