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

@@ -41,6 +41,33 @@ type Config struct {
// active layout from its linked UniFi Protect live view (ProtectView).
// Requires controller credentials available to the daemon. 0 = off.
ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty"`
// Clock overlays a live clock in a screen corner.
Clock Clock `yaml:"clock,omitempty"`
}
// Clock configures the on-screen clock overlay: a small always-on-top window
// showing the current local time, drawn as outlined white text over the video
// so it stays legible on both bright (day) and dark (night) scenes.
type Clock struct {
// Enabled turns the overlay on.
Enabled bool `yaml:"enabled"`
// Timezone is an IANA name (e.g. "America/Denver"); "Local" or empty uses
// the system timezone. DST is handled automatically.
Timezone string `yaml:"timezone,omitempty"`
// Format is a Go time layout. Default "15:04:05" (24-hour with seconds).
// Examples: "3:04:05 PM", "Mon Jan 2 15:04".
Format string `yaml:"format,omitempty"`
// Corner places the overlay: bottom-right (default), bottom-left,
// top-right, top-left.
Corner string `yaml:"corner,omitempty"`
// FontSize is the glyph height in pixels (default 44).
FontSize int `yaml:"font_size,omitempty"`
// Width/Height are the overlay window size in pixels (defaults 300x72).
Width int `yaml:"width,omitempty"`
Height int `yaml:"height,omitempty"`
// Margin is the gap from the screen edges in pixels (default 24).
Margin int `yaml:"margin,omitempty"`
}
// Controller holds UniFi Protect connection details.
@@ -306,6 +333,29 @@ func (c *Config) Defaults() {
if c.Player.RestartBackoffSeconds == 0 {
c.Player.RestartBackoffSeconds = 3
}
if c.Clock.Enabled {
if c.Clock.Timezone == "" {
c.Clock.Timezone = "Local"
}
if c.Clock.Format == "" {
c.Clock.Format = "15:04:05"
}
if c.Clock.Corner == "" {
c.Clock.Corner = "bottom-right"
}
if c.Clock.FontSize == 0 {
c.Clock.FontSize = 44
}
if c.Clock.Width == 0 {
c.Clock.Width = 300
}
if c.Clock.Height == 0 {
c.Clock.Height = 72
}
if c.Clock.Margin == 0 {
c.Clock.Margin = 24
}
}
}
// Validate checks referential integrity and returns the first problem found.
@@ -354,6 +404,14 @@ func (c *Config) Validate() error {
if c.ActiveLayout != "" && !layoutNames[c.ActiveLayout] {
return fmt.Errorf("active_layout %q is not a defined layout", c.ActiveLayout)
}
if c.Clock.Enabled && c.Clock.Corner != "" {
switch c.Clock.Corner {
case "bottom-right", "bottom-left", "top-right", "top-left":
default:
return fmt.Errorf("clock.corner %q must be one of bottom-right, bottom-left, top-right, top-left", c.Clock.Corner)
}
}
return nil
}