Smoother daemon: no-op reloads, batched placement, muted audio, graceful teardown

- Skip stream restarts when a reload resolves to the identical wall
  (same URLs, tile geometry, player settings) — saving an unrelated
  config edit no longer blanks the screen.
- Replace per-tile placement polling (a swaymsg fork per tile per second,
  plus 150ms get_tree polling per tile at startup) with one shared loop:
  a single get_tree snapshot per tick, re-placing only drifted windows,
  relaxing to a 2s tick once settled.
- Mute streams by default (--no-audio) to skip an audio decoder per
  stream; opt back in with player.audio: true.
- Graceful, parallel mpv teardown via cmd.Cancel/WaitDelay, fixing the
  double-Wait race between Stop and Supervise and cutting worst-case
  layout switches from ~2s x N streams to ~2s total.
- status: probe mpv IPC outside the daemon mutex so a slow probe can't
  block layout switches.
- View sync: reuse the Protect session across ticks (re-login only on
  expiry) and pick up view_refresh_seconds changes without a restart.
- Health strikes keyed by player, not slot, so they never carry across
  layout switches; prune stale entries.
- New `rtsp-streamer reload` CLI; fix dead sort in `layout ls`; back off
  on persistent control-socket accept errors; fsync config before the
  atomic rename (SD-card power-cut safety); bump IPC client deadline;
  gofmt stragglers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Woodard
2026-07-02 07:38:01 -05:00
parent 291d37ec83
commit 98ddf1645c
11 changed files with 325 additions and 140 deletions

View File

@@ -10,12 +10,15 @@ import (
"encoding/json"
"fmt"
"os/exec"
"time"
)
// Rect is a pixel rectangle on the output.
// Rect is a pixel rectangle on the output. The JSON tags match sway's
// get_tree/get_outputs "rect" objects so it can be decoded directly.
type Rect struct {
X, Y, W, H int
X int `json:"x"`
Y int `json:"y"`
W int `json:"width"`
H int `json:"height"`
}
// GridRects splits a WxH area into cols*rows cells in row-major order. The
@@ -130,6 +133,7 @@ type treeNode struct {
PID int `json:"pid"`
Name string `json:"name"`
AppID string `json:"app_id"`
Rect Rect `json:"rect"`
Nodes []treeNode `json:"nodes"`
Float []treeNode `json:"floating_nodes"`
}
@@ -144,44 +148,25 @@ func (n *treeNode) walk(fn func(*treeNode)) {
}
}
// hasPID reports whether a window with the given pid currently exists.
func (c *Client) hasPID(ctx context.Context, pid int) (bool, error) {
// WindowRects returns the geometry of every mapped window keyed by owning
// pid, from a single get_tree round trip. The daemon checks all tiles against
// one snapshot instead of issuing a swaymsg per window.
func (c *Client) WindowRects(ctx context.Context) (map[int]Rect, error) {
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_tree", "-r").Output()
if err != nil {
return false, fmt.Errorf("get_tree: %w", err)
return nil, fmt.Errorf("get_tree: %w", err)
}
var root treeNode
if err := json.Unmarshal(out, &root); err != nil {
return false, err
return nil, err
}
found := false
rects := map[int]Rect{}
root.walk(func(n *treeNode) {
if n.PID == pid && (n.AppID != "" || n.Name != "") {
found = true
if n.PID != 0 && (n.AppID != "" || n.Name != "") {
rects[n.PID] = n.Rect
}
})
return found, nil
}
// WaitForWindow blocks until a window owned by pid maps, or ctx/timeout fires.
func (c *Client) WaitForWindow(ctx context.Context, pid int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(150 * time.Millisecond)
defer ticker.Stop()
for {
ok, err := c.hasPID(ctx, pid)
if err == nil && ok {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if time.Now().After(deadline) {
return fmt.Errorf("window for pid %d did not appear within %s", pid, timeout)
}
}
}
return rects, nil
}
// Place floats and positions the window owned by pid at the given rect. Using