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) }