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

@@ -155,6 +155,27 @@ wall — stream URLs, tile geometry, player settings — against what's already
running and leaves the streams untouched when nothing material changed, so
saving an unrelated edit never blanks the screen.
## Clock overlay
An optional always-on-top clock can be rendered in a screen corner:
```yaml
clock:
enabled: true
timezone: America/Denver # IANA name; "Local" uses the system zone. DST auto.
format: "15:04:05" # Go time layout (24-hour w/ seconds)
corner: bottom-right # bottom-right | bottom-left | top-right | top-left
```
It's a tiny transparent mpv window (no extra dependencies) drawing the time as
**white text with a black outline**, so it stays readable over both bright (day)
and dark (night) camera scenes without measuring the picture. The time is
formatted in the configured timezone via Go's zone database, so it's correct
regardless of the host clock's zone and handles DST on its own. The daemon keeps
it positioned and on top across layout switches. Size (`width`/`height`),
`font_size`, and edge `margin` are configurable; enable/disable or retune it
live with an edit plus `rtsp-streamer reload`.
## UniFi Protect live views
Copy a saved Protect "Live View" (its cameras and grid) straight into a layout:

View File

@@ -84,6 +84,21 @@ layouts:
active_layout: quad
# On-screen clock overlay: a small always-on-top window showing the local time,
# drawn as white text with a black outline so it stays legible over both bright
# (day) and dark (night) camera scenes. The time is computed in the given
# timezone (DST handled automatically), independent of the host clock's zone.
clock:
enabled: true
timezone: America/Denver # IANA name; "Local" uses the system zone
format: "15:04:05" # Go time layout (24-hour w/ seconds). Others:
# "3:04:05 PM" · "Mon Jan 2 15:04"
corner: bottom-right # bottom-right | bottom-left | top-right | top-left
font_size: 44 # glyph height in px
width: 300 # overlay window size in px
height: 72
margin: 24 # gap from the screen edges in px
# Re-sync the active layout from its linked UniFi Protect live view every N
# seconds (0 = off). Layouts created by `views import` carry a `protect_view:`
# and are re-synced when this is set. Requires the controller password at

View File

@@ -179,6 +179,19 @@ func (c *Client) Place(ctx context.Context, pid int, r Rect) error {
return c.run(ctx, cmd)
}
// PlaceAndRaise positions the window owned by pid and raises it above the
// other floating windows (by focusing it — sway raises the focused float).
// Used for the clock overlay, which must stay on top of the camera tiles even
// after a layout switch remaps windows over it. There is no keyboard on the
// kiosk, so taking focus has no downside.
func (c *Client) PlaceAndRaise(ctx context.Context, pid int, r Rect) error {
cmd := fmt.Sprintf(
"[pid=%d] floating enable, move absolute position %d %d, resize set %d %d, focus",
pid, r.X, r.Y, r.W, r.H,
)
return c.run(ctx, cmd)
}
// PrepareForMPV installs a global rule so every mpv window maps floating,
// ready for the daemon to position. Borders are already off via the kiosk
// config's `default_border none`, so this rule is a single command: sway's

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
}

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.

191
internal/player/clock.go Normal file
View File

@@ -0,0 +1,191 @@
package player
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/lwoodard/rtsp-streamer/internal/config"
)
// Clock is a small always-on-top mpv window that renders the current local
// time. The window plays a fully transparent lavfi canvas (so the camera video
// shows through) and the time is drawn as an ASS overlay pushed over mpv's IPC
// once a second: white glyphs with a black outline, which stay legible over
// both bright (day) and dark (night) scenes without sampling the picture. The
// time is formatted in Go against a fixed timezone, so it does not depend on
// the host clock's zone and gets DST right.
type Clock struct {
cfg config.Clock
loc *time.Location
runDir string
ipcPath string
log *slog.Logger
mu sync.Mutex
cmd *exec.Cmd
}
// NewClock builds a clock renderer. It fails if the configured timezone is not
// known, so a typo surfaces at startup rather than silently showing UTC.
func NewClock(cfg config.Clock, runDir string, log *slog.Logger) (*Clock, error) {
loc, err := time.LoadLocation(cfg.Timezone)
if err != nil {
return nil, fmt.Errorf("clock timezone %q: %w", cfg.Timezone, err)
}
return &Clock{
cfg: cfg,
loc: loc,
runDir: runDir,
ipcPath: filepath.Join(runDir, "mpv-clock.sock"),
log: log.With("comp", "clock"),
}, nil
}
// Title is the window title, so the compositor can match the clock by title.
func (c *Clock) Title() string { return "rtsp-streamer:clock" }
// PID returns the running mpv pid, or 0.
func (c *Clock) PID() int {
c.mu.Lock()
defer c.mu.Unlock()
if c.cmd == nil || c.cmd.Process == nil {
return 0
}
return c.cmd.Process.Pid
}
func (c *Clock) args() []string {
// A transparent RGBA canvas at a few fps; the text is drawn via osd-overlay,
// so nothing needs to be escaped into the filtergraph. --alpha=yes lets the
// transparent areas composite over the camera windows behind it (if the
// compositor can't do alpha, the canvas is black and the outlined white
// text is still perfectly readable — it just gains a dark backing).
src := fmt.Sprintf("av://lavfi:color=c=black@0.0:s=%dx%d:r=4,format=rgba", c.cfg.Width, c.cfg.Height)
return []string{
"--no-config",
"--force-window=yes",
"--idle=no",
"--keep-open=no",
"--loop-file=inf",
"--no-osc",
"--no-input-default-bindings",
"--input-cursor=no",
"--cursor-autohide=always",
"--no-border",
"--fullscreen=no",
"--no-audio",
"--keepaspect=no",
"--alpha=yes",
"--title=" + c.Title(),
"--input-ipc-server=" + c.ipcPath,
fmt.Sprintf("--geometry=%dx%d", c.cfg.Width, c.cfg.Height),
src,
}
}
func (c *Clock) start(ctx context.Context) error {
_ = os.Remove(c.ipcPath)
cmd := exec.CommandContext(ctx, "mpv", c.args()...)
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 2 * time.Second
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting clock mpv: %w", err)
}
c.mu.Lock()
c.cmd = cmd
c.mu.Unlock()
c.log.Info("clock started", "pid", cmd.Process.Pid, "tz", c.cfg.Timezone)
return nil
}
// Supervise runs the clock mpv, relaunching it if it exits, and pushes the
// current time to it every second while it is alive. Returns when ctx is done.
func (c *Clock) Supervise(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
if err := c.start(ctx); err != nil {
c.log.Error("failed to start clock", "err", err)
if !sleep(ctx, 3*time.Second) {
return
}
continue
}
c.mu.Lock()
cmd := c.cmd
c.mu.Unlock()
done := make(chan struct{})
go c.tick(ctx, done)
_ = cmd.Wait()
close(done)
c.mu.Lock()
c.cmd = nil
c.mu.Unlock()
if ctx.Err() != nil {
return
}
c.log.Warn("clock mpv exited, will restart")
if !sleep(ctx, 3*time.Second) {
return
}
}
}
// tick pushes the time to mpv on second boundaries until done or ctx is done.
func (c *Clock) tick(ctx context.Context, done <-chan struct{}) {
// Nudge onto the next whole second, then tick each second, so the displayed
// seconds flip close to real wall-clock seconds.
timer := time.NewTimer(10 * time.Millisecond)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-done:
return
case <-timer.C:
}
c.push()
now := time.Now()
timer.Reset(time.Second - time.Duration(now.Nanosecond()))
}
}
// push renders the current time as ASS and sends it as an OSD overlay. an5
// centers it in the window; \bord gives the black outline, \1c/\3c set the
// white fill and black outline colors (ASS is &HBBGGRR&).
func (c *Clock) push() {
text := assEscape(time.Now().In(c.loc).Format(c.cfg.Format))
data := fmt.Sprintf(
`{\an5\fs%d\bord3\shad1\1c&HFFFFFF&\3c&H000000&\4c&H000000&\b1}%s`,
c.cfg.FontSize, text,
)
if _, err := ipcCommand(c.ipcPath, "osd-overlay", 1, "ass-events", data, 0, c.cfg.Height, 0, false, false); err != nil {
c.log.Debug("clock overlay update failed", "err", err)
}
}
// assEscape drops the few characters that are special in ASS override text, so
// an unusual time format string can't break rendering. These never appear in a
// rendered time, so dropping them is harmless.
func assEscape(s string) string {
r := make([]rune, 0, len(s))
for _, ch := range s {
if ch == '{' || ch == '}' || ch == '\\' {
continue
}
r = append(r, ch)
}
return string(r)
}

View File

@@ -245,7 +245,14 @@ func (p *Player) Stop() {
// Command sends a JSON IPC command to mpv and returns the decoded reply. Used
// for health probes and live property changes.
func (p *Player) Command(args ...any) (map[string]any, error) {
conn, err := net.DialTimeout("unix", p.ipcPath, 2*time.Second)
return ipcCommand(p.ipcPath, args...)
}
// ipcCommand sends one JSON command to an mpv IPC socket and returns the
// decoded reply. Shared by Player (health probes, reload) and Clock (overlay
// updates).
func ipcCommand(path string, args ...any) (map[string]any, error) {
conn, err := net.DialTimeout("unix", path, 2*time.Second)
if err != nil {
return nil, err
}