diff --git a/README.md b/README.md index 0821e7d..338eb6a 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/config.example.yaml b/config.example.yaml index 2a9089e..140fe77 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/internal/compositor/compositor.go b/internal/compositor/compositor.go index 44cf4e5..5e76ed5 100644 --- a/internal/compositor/compositor.go +++ b/internal/compositor/compositor.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index ac2ee58..0fc79bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/daemon/clock_test.go b/internal/daemon/clock_test.go new file mode 100644 index 0000000..f61c313 --- /dev/null +++ b/internal/daemon/clock_test.go @@ -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) + } + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5174fc0..7b28029 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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. diff --git a/internal/player/clock.go b/internal/player/clock.go new file mode 100644 index 0000000..7d58d66 --- /dev/null +++ b/internal/player/clock.go @@ -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) +} diff --git a/internal/player/player.go b/internal/player/player.go index 9b19246..a88cfda 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -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 }