package player import ( "context" "fmt" "log/slog" "os" "os/exec" "path/filepath" "strings" "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: 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. // // The text is baked into the canvas by an ffmpeg drawtext filter reading // textPath, which the ticker rewrites once a second (drawtext's reload=1 re-reads // the file every frame). It is deliberately NOT drawn as an mpv OSD overlay: // on mpv 0.35 + Mesa/V3D + sway, osd-overlay renders only on the single frame // where its content changes, so an IPC-pushed clock appears for ~200ms a second // and reads as a flashing clock. Baking the text into every frame is stable. // // Because the text is rendered when a frame is *generated*, the canvas must not // be buffered ahead of display or the visible time lags — see args() for the // cache flags that keep generation just-in-time. type Clock struct { cfg config.Clock loc *time.Location runDir string textPath 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) } textPath := filepath.Join(runDir, "clock-text.txt") // A ':' or '\' in the path would be read as filtergraph syntax and break the // drawtext option rather than pointing at the file. if strings.ContainsAny(textPath, `:\`) { return nil, fmt.Errorf("clock text path %q contains a character that cannot be escaped in a filtergraph", textPath) } c := &Clock{ cfg: cfg, loc: loc, runDir: runDir, textPath: textPath, log: log.With("comp", "clock"), } // drawtext fails to initialise if the file is missing, which would take the // whole window down, so seed it before mpv ever starts. if err := c.writeText(); err != nil { return nil, fmt.Errorf("seeding clock text: %w", err) } return c, nil } // writeText renders the current time and replaces textPath atomically, so // drawtext never reads a half-written file. func (c *Clock) writeText() error { tmp := c.textPath + ".tmp" if err := os.WriteFile(tmp, []byte(time.Now().In(c.loc).Format(c.cfg.Format)), 0o644); err != nil { return err } return os.Rename(tmp, c.textPath) } // 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 mostly-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 canvas composite over the camera windows behind it. // // The canvas alpha must stay > 0. mpv blends the OSD into the canvas, and // on a fully transparent one the text inherits alpha 0, so the compositor // draws nothing at all and the clock silently vanishes (observed on // Mesa/V3D + sway). BackgroundOpacity is clamped to a non-zero default in // config; the dark backing it produces also keeps the white text legible // against bright daytime scenes. // Clamped here too, not just in config, so a Clock built by any other path // still cannot render itself invisible. opacity := c.cfg.BackgroundOpacity if opacity <= 0 { opacity = 0.45 } else if opacity > 1 { opacity = 1 } // borderw draws the black outline that keeps white glyphs readable against a // bright scene; the text is centred on the canvas. src := fmt.Sprintf( "av://lavfi:color=c=black@%.3f:s=%dx%d:r=4,format=rgba,"+ "drawtext=textfile=%s:reload=1:fontsize=%d:fontcolor=white:"+ "borderw=3:bordercolor=black:x=(w-text_w)/2:y=(h-text_h)/2", opacity, c.cfg.Width, c.cfg.Height, c.textPath, c.cfg.FontSize) 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", // drawtext stamps the time when a frame is GENERATED, so any readahead // shows a stale clock (buffering a few seconds ahead made the displayed // time lag by that much and swallowed text updates entirely). Keep // generation just-in-time. "--cache=no", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=64KiB", "--profile=low-latency", "--title=" + c.Title(), fmt.Sprintf("--geometry=%dx%d", c.cfg.Width, c.cfg.Height), src, } } // start launches the clock mpv. It needs no IPC socket: the time reaches the // window through textPath, which drawtext re-reads every frame. func (c *Clock) start(ctx context.Context) error { 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 publishes the current time for the drawtext filter to pick up on its // next frame. Failures are logged at warn, not debug: a clock that stops // updating is silently wrong, which is worse than one that is visibly absent. func (c *Clock) push() { if err := c.writeText(); err != nil { c.log.Warn("clock text update failed", "path", c.textPath, "err", err) } }