Compare commits
2 Commits
caed091dc2
...
1ea3a5ac0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ea3a5ac0a | ||
|
|
81193f524c |
65
Makefile
65
Makefile
@@ -2,6 +2,13 @@ BINARY := rtsp-streamer
|
|||||||
PKG := ./cmd/rtsp-streamer
|
PKG := ./cmd/rtsp-streamer
|
||||||
BINDIR := bin
|
BINDIR := bin
|
||||||
|
|
||||||
|
# The interactive configurator is a separate executable: it is built with
|
||||||
|
# opentui, which is a TypeScript library, so Bun compiles it rather than Go.
|
||||||
|
# See tui/README.md.
|
||||||
|
TUIBINARY := rtsp-streamer-tui
|
||||||
|
TUIDIR := tui
|
||||||
|
BUN ?= $(shell command -v bun 2>/dev/null || echo $$HOME/.bun/bin/bun)
|
||||||
|
|
||||||
# Build metadata baked into the binary (see `rtsp-streamer version`).
|
# Build metadata baked into the binary (see `rtsp-streamer version`).
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||||
@@ -14,13 +21,16 @@ LDFLAGS := -s -w \
|
|||||||
# Install location. Override with `make install PREFIX=/opt`.
|
# Install location. Override with `make install PREFIX=/opt`.
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
DESTBIN := $(PREFIX)/bin/$(BINARY)
|
DESTBIN := $(PREFIX)/bin/$(BINARY)
|
||||||
|
DESTTUI := $(PREFIX)/bin/$(TUIBINARY)
|
||||||
|
|
||||||
# Use sudo automatically when not already root, so `make install` /
|
# Use sudo automatically when not already root, so `make install` /
|
||||||
# `make deploy` work whether or not you prefix them with sudo.
|
# `make deploy` work whether or not you prefix them with sudo.
|
||||||
SUDO := $(shell [ "$$(id -u)" -eq 0 ] || echo sudo)
|
SUDO := $(shell [ "$$(id -u)" -eq 0 ] || echo sudo)
|
||||||
|
|
||||||
.PHONY: all build pi pi32 test vet clean run-tui install deploy uninstall
|
.PHONY: all build pi pi32 test test-tui vet clean run-tui tui tui-pi tui-deps install deploy uninstall
|
||||||
|
|
||||||
|
# Go only, so a plain `make` still works on a machine without Bun. The
|
||||||
|
# configurator is an explicit `make tui`.
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
## build: compile for the host platform (native arch)
|
## build: compile for the host platform (native arch)
|
||||||
@@ -35,11 +45,42 @@ pi:
|
|||||||
pi32:
|
pi32:
|
||||||
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags '$(LDFLAGS)' -o $(BINDIR)/$(BINARY)-armv7 $(PKG)
|
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags '$(LDFLAGS)' -o $(BINDIR)/$(BINARY)-armv7 $(PKG)
|
||||||
|
|
||||||
## install: build for this machine and copy the binary to $(PREFIX)/bin
|
## tui-deps: install the configurator's node_modules (needs Bun)
|
||||||
## (auto-sudo). This is the location the running daemon uses.
|
tui-deps:
|
||||||
|
@command -v $(BUN) >/dev/null 2>&1 || { \
|
||||||
|
echo "bun not found. Install it with: curl -fsSL https://bun.sh/install | bash"; \
|
||||||
|
exit 1; }
|
||||||
|
cd $(TUIDIR) && $(BUN) install
|
||||||
|
|
||||||
|
## tui: compile the opentui configurator to a standalone binary. Bun embeds its
|
||||||
|
## own runtime and opentui's native library, so the result needs nothing
|
||||||
|
## installed on the target — but it is ~120 MB, unlike the ~8 MB Go binary.
|
||||||
|
tui: tui-deps
|
||||||
|
cd $(TUIDIR) && $(BUN) build --compile src/index.ts --outfile ../$(BINDIR)/$(TUIBINARY)
|
||||||
|
|
||||||
|
## tui-pi: cross-compile the configurator for a 64-bit Pi (arm64), the analogue
|
||||||
|
## of `make pi`. Pulls in the arm64 native package first, since Bun embeds
|
||||||
|
## opentui's native library rather than resolving it at run time. Defining
|
||||||
|
## OPENTUI_LIBC lets Bun drop the musl branch and embed only glibc.
|
||||||
|
tui-pi:
|
||||||
|
cd $(TUIDIR) && $(BUN) install --os=linux --cpu=arm64
|
||||||
|
cd $(TUIDIR) && $(BUN) build --compile --target=bun-linux-arm64 \
|
||||||
|
--define process.env.OPENTUI_LIBC='"glibc"' \
|
||||||
|
src/index.ts --outfile ../$(BINDIR)/$(TUIBINARY)-arm64
|
||||||
|
|
||||||
|
## install: build for this machine and copy the binaries to $(PREFIX)/bin
|
||||||
|
## (auto-sudo). This is the location the running daemon uses. The configurator
|
||||||
|
## is installed alongside it, which is where `rtsp-streamer tui` looks first.
|
||||||
install: build
|
install: build
|
||||||
$(SUDO) install -m 0755 $(BINDIR)/$(BINARY) $(DESTBIN)
|
$(SUDO) install -m 0755 $(BINDIR)/$(BINARY) $(DESTBIN)
|
||||||
@echo "installed -> $(DESTBIN)"
|
@echo "installed -> $(DESTBIN)"
|
||||||
|
@if [ -x "$(BINDIR)/$(TUIBINARY)" ]; then \
|
||||||
|
$(SUDO) install -m 0755 $(BINDIR)/$(TUIBINARY) $(DESTTUI); \
|
||||||
|
echo "installed -> $(DESTTUI)"; \
|
||||||
|
else \
|
||||||
|
echo "note: $(TUIBINARY) not built (run 'make tui'); 'rtsp-streamer tui' will"; \
|
||||||
|
echo " not start until it is. 'rtsp-streamer tui --legacy' works regardless."; \
|
||||||
|
fi
|
||||||
|
|
||||||
## deploy: install, then restart the running wall in place (no reboot). Run as
|
## deploy: install, then restart the running wall in place (no reboot). Run as
|
||||||
## the kiosk user so it reaches the daemon's control socket. Falls back to a
|
## the kiosk user so it reaches the daemon's control socket. Falls back to a
|
||||||
@@ -48,22 +89,28 @@ deploy: install
|
|||||||
@$(DESTBIN) restart || echo "daemon not running; it will start on next boot"
|
@$(DESTBIN) restart || echo "daemon not running; it will start on next boot"
|
||||||
@echo "run 'rtsp-streamer status' to check"
|
@echo "run 'rtsp-streamer status' to check"
|
||||||
|
|
||||||
## uninstall: remove the installed binary
|
## uninstall: remove the installed binaries
|
||||||
uninstall:
|
uninstall:
|
||||||
$(SUDO) rm -f $(DESTBIN)
|
$(SUDO) rm -f $(DESTBIN) $(DESTTUI)
|
||||||
@echo "removed $(DESTBIN)"
|
@echo "removed $(DESTBIN) and $(DESTTUI)"
|
||||||
|
|
||||||
## test: run unit tests
|
## test: run Go unit tests
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
## test-tui: run the configurator's tests (needs Bun)
|
||||||
|
test-tui: tui-deps
|
||||||
|
cd $(TUIDIR) && $(BUN) test && $(BUN) x tsc --noEmit
|
||||||
|
|
||||||
## vet: run go vet
|
## vet: run go vet
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
## run-tui: launch the configurator against a scratch config
|
## run-tui: launch the configurator from source against a scratch config, with
|
||||||
|
## no compile step — the fast loop while working on tui/.
|
||||||
run-tui: build
|
run-tui: build
|
||||||
RTSP_STREAMER_CONFIG=/tmp/rtsp-streamer.yaml $(BINDIR)/$(BINARY) tui
|
cd $(TUIDIR) && RTSP_STREAMER_BIN=$(CURDIR)/$(BINDIR)/$(BINARY) \
|
||||||
|
$(BUN) run src/index.ts --config /tmp/rtsp-streamer.yaml
|
||||||
|
|
||||||
## clean: remove build artifacts
|
## clean: remove build artifacts
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
225
README.md
225
README.md
@@ -49,6 +49,20 @@ Arch: `sudo pacman -S sway mpv foot`
|
|||||||
To build: **Go 1.23+**. You can build on your dev machine and copy the binary,
|
To build: **Go 1.23+**. You can build on your dev machine and copy the binary,
|
||||||
or build on the Pi itself.
|
or build on the Pi itself.
|
||||||
|
|
||||||
|
The interactive configurator is a second binary built with
|
||||||
|
[Bun](https://bun.sh) (it's written in TypeScript — see
|
||||||
|
[the TUI section](#the-tui)). It is **optional**: `make` builds only the Go
|
||||||
|
binary, and everything except `rtsp-streamer tui` works without it. Install Bun
|
||||||
|
only if you want the configurator:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://bun.sh/install | bash # then: make tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Neither Bun nor Node is needed at *run* time — `make tui` produces a standalone
|
||||||
|
executable. And `rtsp-streamer tui --legacy` is built into the Go binary, so a
|
||||||
|
box without Bun still has a configurator.
|
||||||
|
|
||||||
> ⚠️ Debian / Raspberry Pi OS ships **Go 1.19** via `apt`, which is too old
|
> ⚠️ Debian / Raspberry Pi OS ships **Go 1.19** via `apt`, which is too old
|
||||||
> (`log/slog` needs 1.21+). Install a current Go into `~/.local/go` and put it
|
> (`log/slog` needs 1.21+). Install a current Go into `~/.local/go` and put it
|
||||||
> first on `PATH` — do **not** rely on the system `go`:
|
> first on `PATH` — do **not** rely on the system `go`:
|
||||||
@@ -71,6 +85,21 @@ make deploy # install + restart the kiosk session
|
|||||||
rtsp-streamer version # prints the baked-in git commit / build date
|
rtsp-streamer version # prints the baked-in git commit / build date
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The configurator is built separately (needs Bun; skip it and use
|
||||||
|
`rtsp-streamer tui --legacy`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make tui # host arch -> bin/rtsp-streamer-tui (standalone, ~120 MB)
|
||||||
|
make tui-pi # arm64 -> bin/rtsp-streamer-tui-arm64 (the `make pi` analogue)
|
||||||
|
make test-tui # bun test + tsc --noEmit
|
||||||
|
make run-tui # run it from source against /tmp/rtsp-streamer.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`make install` copies `bin/rtsp-streamer-tui` alongside the main binary when it
|
||||||
|
exists, which is where `rtsp-streamer tui` looks for it. If you cross-compiled
|
||||||
|
with `make tui-pi`, rename the `-arm64` artifact to `rtsp-streamer-tui` on the
|
||||||
|
target, or point `$RTSP_STREAMER_TUI` at it.
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -97,23 +126,35 @@ may not overlap. See `main-plus` in [`config.example.yaml`](config.example.yaml)
|
|||||||
|
|
||||||
### The TUI
|
### The TUI
|
||||||
|
|
||||||
`rtsp-streamer tui` is a Bubble Tea configurator meant to be run over SSH:
|
`rtsp-streamer tui` is an [opentui](https://opentui.com) configurator meant to be
|
||||||
|
run over SSH:
|
||||||
|
|
||||||
- **Cameras** — review discovered cameras, `d` disable, `x` delete.
|
- **Cameras** — review discovered cameras, `d` disable, `x` delete.
|
||||||
- **Layouts** — edit an arrangement on a live ASCII grid preview.
|
- **Layouts** — edit an arrangement on a live grid preview.
|
||||||
- **Set active layout** — choose what the wall shows.
|
- **Set active layout** — choose what the wall shows.
|
||||||
- **Discover** / **Discover + enable RTSP (high)** — pull cameras from Protect.
|
- **Discover** / **Discover + enable RTSP (hi+lo)** — pull cameras from Protect.
|
||||||
- **Save** — writes the config and tells a running daemon to reload live.
|
- **Save** — writes the config and tells a running daemon to reload live.
|
||||||
|
|
||||||
In the **layout editor** the grid is drawn live as you edit:
|
It is a separate executable (`rtsp-streamer-tui`) built by Bun, because opentui is
|
||||||
|
a TypeScript library — see [`tui/README.md`](tui/README.md) for the architecture
|
||||||
|
and the Go↔TS bridge. Build it with `make tui`; `make install` puts it alongside
|
||||||
|
the main binary. The previous Bubble Tea implementation is still available as
|
||||||
|
`rtsp-streamer tui --legacy`, which needs no Bun runtime.
|
||||||
|
|
||||||
|
In the **layout editor** the grid is drawn live as you edit, with a spanning tile
|
||||||
|
shown as a single box:
|
||||||
|
|
||||||
```
|
```
|
||||||
+----------+----------+----------+----------+
|
┌─Driveway─────────────────────────────────────┐┌─Front Door───┐
|
||||||
|Front Door| · | · |Driveway |
|
│ 3x2 hi ││ lo │
|
||||||
|3x3 | · | · | |
|
│ ││ │
|
||||||
+----------+----------+----------+----------+
|
│ │└──────────────┘
|
||||||
| · | · | · |Back Yard |
|
│ │┌──────────────┐
|
||||||
...
|
│ ││ │
|
||||||
|
└──────────────────────────────────────────────┘└──────────────┘
|
||||||
|
┌──────────────┐┌──────────────┐┌──────────────┐┌─Back Yard────┐
|
||||||
|
│ ││ ││ ││ auto │
|
||||||
|
└──────────────┘└──────────────┘└──────────────┘└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **mouse (over SSH):** click a cell to assign a camera; **press on a tile and
|
- **mouse (over SSH):** click a cell to assign a camera; **press on a tile and
|
||||||
@@ -127,6 +168,7 @@ In the **layout editor** the grid is drawn live as you edit:
|
|||||||
- `esc` back
|
- `esc` back
|
||||||
|
|
||||||
Elsewhere: arrows / `j`/`k` move (or click), `enter` selects, `esc` back, `q` quits.
|
Elsewhere: arrows / `j`/`k` move (or click), `enter` selects, `esc` back, `q` quits.
|
||||||
|
`S` saves from any screen.
|
||||||
|
|
||||||
### Stream quality per tile
|
### Stream quality per tile
|
||||||
|
|
||||||
@@ -155,26 +197,93 @@ wall — stream URLs, tile geometry, player settings — against what's already
|
|||||||
running and leaves the streams untouched when nothing material changed, so
|
running and leaves the streams untouched when nothing material changed, so
|
||||||
saving an unrelated edit never blanks the screen.
|
saving an unrelated edit never blanks the screen.
|
||||||
|
|
||||||
|
### JSON interface
|
||||||
|
|
||||||
|
Three commands speak JSON. The configurator in `tui/` is built on them, and
|
||||||
|
they're the supported way to script config changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rtsp-streamer config export # whole config as JSON on stdout
|
||||||
|
rtsp-streamer config apply < edits.json # merge, validate, save, reload
|
||||||
|
rtsp-streamer discover --json # Protect discovery, writes nothing
|
||||||
|
rtsp-streamer discover --json --enable-rtsp=high,low
|
||||||
|
```
|
||||||
|
|
||||||
|
`config apply` reads an object with any of `cameras`, `layouts` and
|
||||||
|
`active_layout`, and **merges** it into a fresh read of the file — absent keys are
|
||||||
|
left alone, and the `controller` section is never touched. So a one-key edit needs
|
||||||
|
nothing else in the payload:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo '{"active_layout": "quad"}' | rtsp-streamer config apply
|
||||||
|
```
|
||||||
|
|
||||||
|
(That is exactly what `layout set quad` does, and it leaves cameras and layouts
|
||||||
|
untouched. `--json` output pairs well with `jq` if you have it.)
|
||||||
|
|
||||||
|
Two deliberate properties:
|
||||||
|
|
||||||
|
- **The controller password never appears in `config export`,** and `apply` can't
|
||||||
|
overwrite it. Secrets stay in the file (or `$RTSP_STREAMER_PASSWORD`).
|
||||||
|
- **`apply` re-reads before merging,** so a long-running editor can't clobber a
|
||||||
|
`views import`, a `layout set`, or a hand edit made in the meantime.
|
||||||
|
|
||||||
|
`discover --json` writes nothing, which makes discovery previewable — only
|
||||||
|
`--enable-rtsp` has a side effect, and it is on the *controller* (it switches
|
||||||
|
RTSP on for those channels in Protect).
|
||||||
|
|
||||||
|
Every one of the three prints a JSON document on stdout even when it fails, with
|
||||||
|
an `ok` field, and exits non-zero on failure.
|
||||||
|
|
||||||
## Clock overlay
|
## Clock overlay
|
||||||
|
|
||||||
An optional always-on-top clock can be rendered in a screen corner:
|
An optional always-on-top clock can be rendered at a screen edge:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
clock:
|
clock:
|
||||||
enabled: true
|
enabled: true
|
||||||
timezone: America/Denver # IANA name; "Local" uses the system zone. DST auto.
|
timezone: America/Denver # IANA name; "Local" uses the system zone. DST auto.
|
||||||
format: "15:04:05" # Go time layout (24-hour w/ seconds)
|
format: "15:04:05" # Go time layout (24-hour w/ seconds)
|
||||||
corner: bottom-right # bottom-right | bottom-left | top-right | top-left
|
corner: bottom-center # bottom-right | bottom-left | top-right | top-left
|
||||||
|
# | bottom-center | top-center
|
||||||
|
background_opacity: 0.45 # alpha of the backing box; must be > 0 (see below)
|
||||||
```
|
```
|
||||||
|
|
||||||
It's a tiny transparent mpv window (no extra dependencies) drawing the time as
|
It's a tiny mpv window (no extra dependencies) drawing the time as **white text
|
||||||
**white text with a black outline**, so it stays readable over both bright (day)
|
with a black outline** over a dimmed backing box, so it stays readable over both
|
||||||
and dark (night) camera scenes without measuring the picture. The time is
|
bright (day) and dark (night) camera scenes without measuring the picture. The
|
||||||
formatted in the configured timezone via Go's zone database, so it's correct
|
time is formatted in the configured timezone via Go's zone database, so it's
|
||||||
regardless of the host clock's zone and handles DST on its own. The daemon keeps
|
correct regardless of the host clock's zone and handles DST on its own. The
|
||||||
it positioned and on top across layout switches. Size (`width`/`height`),
|
daemon keeps it positioned and on top across layout switches. Size
|
||||||
`font_size`, and edge `margin` are configurable; enable/disable or retune it
|
(`width`/`height`), `font_size`, and edge `margin` are configurable;
|
||||||
live with an edit plus `rtsp-streamer reload`.
|
enable/disable or retune it live with an edit plus `rtsp-streamer reload`.
|
||||||
|
|
||||||
|
The `*-center` positions center the window horizontally and ignore `margin` on
|
||||||
|
that axis; `margin` still applies vertically.
|
||||||
|
|
||||||
|
### Why the clock is drawn the way it is
|
||||||
|
|
||||||
|
The time is baked into every video frame by an ffmpeg `drawtext` filter that
|
||||||
|
re-reads a small text file (`$XDG_RUNTIME_DIR/rtsp-streamer/clock-text.txt`),
|
||||||
|
which the daemon rewrites once a second. That looks roundabout — pushing an ASS
|
||||||
|
`osd-overlay` over mpv's IPC would be the obvious approach — but on mpv 0.35 +
|
||||||
|
Mesa/V3D + sway an OSD overlay is rendered **only on the frame where its content
|
||||||
|
changes**, so an IPC-driven clock is visible for roughly 200ms per second and
|
||||||
|
reads as a flashing clock. Every layer reports success while this happens
|
||||||
|
(`error: success` from mpv, `vo-configured: true`, sway reporting the window
|
||||||
|
visible at the right rect), so it looks like a stacking or font bug and is
|
||||||
|
neither. Two constraints follow, and both are covered by tests:
|
||||||
|
|
||||||
|
- **`background_opacity` must be greater than zero.** A fully transparent canvas
|
||||||
|
(`color=c=black@0.0` with `--alpha=yes`) makes the drawn glyphs inherit alpha
|
||||||
|
0, and the compositor shows nothing at all. Values of `0` or below — including
|
||||||
|
the field being absent — are clamped to the default.
|
||||||
|
- **The canvas must not be buffered ahead** (`--cache=no`,
|
||||||
|
`--demuxer-readahead-secs=0`). `drawtext` stamps the time when a frame is
|
||||||
|
*generated*, so reading ahead makes the displayed clock lag by the readahead
|
||||||
|
and swallows text-file updates entirely.
|
||||||
|
|
||||||
|
Because the text arrives through a file, the clock window needs no IPC socket.
|
||||||
|
|
||||||
## UniFi Protect live views
|
## UniFi Protect live views
|
||||||
|
|
||||||
@@ -187,10 +296,29 @@ rtsp-streamer views import --all # import every view
|
|||||||
rtsp-streamer views dump # raw view JSON (for tuning odd layouts)
|
rtsp-streamer views dump # raw view JSON (for tuning odd layouts)
|
||||||
```
|
```
|
||||||
|
|
||||||
Cameras are matched by Protect id, so run `discover` first. Grid size is
|
Cameras are matched by Protect id, so run `discover` first. An imported layout is
|
||||||
inferred from the slot count (asymmetric "1 big + N" presets land as an even
|
**linked** to its view (`protect_view:` in the config).
|
||||||
grid for now — send me `views dump` output to map exact sizing). An imported
|
|
||||||
layout is **linked** to its view (`protect_view:` in the config).
|
Protect's arrangement for a view is not always an even grid. Known asymmetric
|
||||||
|
presets are mapped exactly; any other slot count falls back to a near-square grid
|
||||||
|
of equal tiles:
|
||||||
|
|
||||||
|
| Slots | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| 8 | `5x4` — four 2x2 tiles plus a right-hand column of four 1x1 tiles |
|
||||||
|
| anything else | near-square uniform grid (`GridForSlots`) |
|
||||||
|
|
||||||
|
To add another preset, run `views dump`, note the view's `layout` value and slot
|
||||||
|
order, and add an entry to `presets` in `internal/viewmap/viewmap.go`. Slot order
|
||||||
|
in the dump is the order tiles are filled.
|
||||||
|
|
||||||
|
Import **warns** whenever a mapping would leave grid cells empty (they show as
|
||||||
|
blank rectangles on the wall) or references a camera that isn't in the config:
|
||||||
|
|
||||||
|
```
|
||||||
|
! Office View: 1 of 9 cells in the 3x3 grid are empty and will show as blank areas
|
||||||
|
! Office View: slot 4 camera 6a65…c6d not in config (run `discover`)
|
||||||
|
```
|
||||||
|
|
||||||
### Auto-resync
|
### Auto-resync
|
||||||
|
|
||||||
@@ -234,6 +362,14 @@ Then `sudo reboot` and the wall comes up on boot.
|
|||||||
git pull && make deploy
|
git pull && make deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make deploy` covers the daemon and the CLI. It does **not** rebuild the
|
||||||
|
configurator — that is a separate, slower build, and it only matters when
|
||||||
|
something under `tui/` changed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git pull && make tui && make deploy
|
||||||
|
```
|
||||||
|
|
||||||
`make deploy` builds natively, copies to `/usr/local/bin` (auto-sudo — run it
|
`make deploy` builds natively, copies to `/usr/local/bin` (auto-sudo — run it
|
||||||
*without* `sudo` so `go build` keeps your `PATH`), then runs
|
*without* `sudo` so `go build` keeps your `PATH`), then runs
|
||||||
`rtsp-streamer restart`, which tells the running daemon to **re-exec itself in
|
`rtsp-streamer restart`, which tells the running daemon to **re-exec itself in
|
||||||
@@ -258,7 +394,7 @@ rtsp-streamer discover --enable-rtsp=high # enable the High channel where miss
|
|||||||
rtsp-streamer discover --enable-rtsp=low # or the Low substream
|
rtsp-streamer discover --enable-rtsp=low # or the Low substream
|
||||||
```
|
```
|
||||||
|
|
||||||
In the TUI, the menu item **"Discover + enable RTSP (high)"** does the same.
|
In the TUI, the menu item **"Discover + enable RTSP (hi+lo)"** does the same.
|
||||||
Enabling preserves each channel's other encoder settings — it only flips the
|
Enabling preserves each channel's other encoder settings — it only flips the
|
||||||
`isRtspEnabled` flag. Plain `discover` (no flag) never modifies your controller;
|
`isRtspEnabled` flag. Plain `discover` (no flag) never modifies your controller;
|
||||||
it just skips cameras with no RTSP-enabled channel and reports which ones.
|
it just skips cameras with no RTSP-enabled channel and reports which ones.
|
||||||
@@ -321,6 +457,26 @@ pegged, work through:
|
|||||||
- **Blank/black tile** — verify the stream directly:
|
- **Blank/black tile** — verify the stream directly:
|
||||||
`mpv --rtsp-transport=tcp 'rtsps://HOST:7441/ALIAS?enableSrtp'`. If it fails,
|
`mpv --rtsp-transport=tcp 'rtsps://HOST:7441/ALIAS?enableSrtp'`. If it fails,
|
||||||
RTSP isn't enabled for that camera (`discover --enable-rtsp=high,low`).
|
RTSP isn't enabled for that camera (`discover --enable-rtsp=high,low`).
|
||||||
|
- **A tile stays blank but its mpv process is running** — the camera entry is
|
||||||
|
stale. Re-adopting a camera in Protect gives it a **new id and often a slightly
|
||||||
|
different name** (`Livingroom` → `Living Room`), and `discover` only adds and
|
||||||
|
updates, it never prunes — so the old entry lingers with an alias the controller
|
||||||
|
no longer serves. Process counts look healthy because mpv is up and retrying.
|
||||||
|
Diff your config's aliases against the controller:
|
||||||
|
`curl -sk -b cookies.txt https://HOST/proxy/protect/api/bootstrap` and compare
|
||||||
|
each camera's `channels[].rtspAlias`. Delete the dead entries, then fix every
|
||||||
|
layout that referenced them (watch for a camera ending up twice after a rename).
|
||||||
|
- **Cameras in `cameras:` aren't on screen** — only cameras placed as tiles in the
|
||||||
|
**active layout** are streamed, one mpv per tile. The `cameras:` list is just an
|
||||||
|
inventory. Check with `rtsp-streamer layout ls` (it prints each layout's tile
|
||||||
|
count).
|
||||||
|
- **Black bars inside every tile** — the base grid's cells aren't 16:9, so mpv
|
||||||
|
letterboxes each feed to fit. On a 16:9 output only a *square* grid (`NxN`)
|
||||||
|
yields 16:9 cells; e.g. a `5x4` grid on 3840x2160 gives 768x540 (1.42:1) cells.
|
||||||
|
Some arrangements can't avoid this at all — two 16:9 tiles 1080px tall need the
|
||||||
|
full 3840px width, leaving no room for a side column. To fill instead of pad,
|
||||||
|
crop with `player.extra_args: ["--panscan=1.0"]`, at the cost of roughly the top
|
||||||
|
and bottom 20% of each camera's field of view.
|
||||||
- **A tile's video is cut off / overflows** — should not happen (mpv is told
|
- **A tile's video is cut off / overflows** — should not happen (mpv is told
|
||||||
`--keepaspect-window=no` and the daemon re-squares tiles every second); if it
|
`--keepaspect-window=no` and the daemon re-squares tiles every second); if it
|
||||||
does, confirm `rtsp-streamer version` is a recent build.
|
does, confirm `rtsp-streamer version` is a recent build.
|
||||||
@@ -331,6 +487,22 @@ pegged, work through:
|
|||||||
- **Build error `package log/slog is not in GOROOT`** — you're on Debian's Go
|
- **Build error `package log/slog is not in GOROOT`** — you're on Debian's Go
|
||||||
1.19; install Go 1.23 into `~/.local/go` and prepend it to `PATH` (see Build).
|
1.19; install Go 1.23 into `~/.local/go` and prepend it to `PATH` (see Build).
|
||||||
|
|
||||||
|
- **`rtsp-streamer tui` says `rtsp-streamer-tui not found`** — the configurator
|
||||||
|
binary isn't built or isn't installed. Either build it (`make tui && make
|
||||||
|
install`, needs Bun) or use `rtsp-streamer tui --legacy`, which is compiled into
|
||||||
|
the Go binary and always available. `$RTSP_STREAMER_TUI` overrides the path.
|
||||||
|
|
||||||
|
- **`rtsp-streamer tui` exits immediately with a config error** — the configurator
|
||||||
|
loads the config through `rtsp-streamer config export` *before* taking over the
|
||||||
|
terminal, so an invalid config surfaces as a plain error rather than a broken
|
||||||
|
screen. Run `rtsp-streamer config validate` to see the same problem.
|
||||||
|
|
||||||
|
- **The configurator can't reach the Go binary** — it shells back to
|
||||||
|
`rtsp-streamer` for every config and Protect operation, resolved from
|
||||||
|
`$RTSP_STREAMER_BIN` (set automatically by `rtsp-streamer tui`) and otherwise
|
||||||
|
from `$PATH`. Running the compiled TUI directly from a shell where
|
||||||
|
`rtsp-streamer` isn't on `PATH` is the usual cause.
|
||||||
|
|
||||||
## Caveats & scope
|
## Caveats & scope
|
||||||
|
|
||||||
- **Unofficial API.** UniFi Protect has no public API; the local endpoints used
|
- **Unofficial API.** UniFi Protect has no public API; the local endpoints used
|
||||||
@@ -363,6 +535,7 @@ internal/player/ one supervised mpv per stream, JSON IPC, restart/backoff
|
|||||||
internal/compositor/ sway control + tile geometry
|
internal/compositor/ sway control + tile geometry
|
||||||
internal/daemon/ orchestrator + control socket + health loop
|
internal/daemon/ orchestrator + control socket + health loop
|
||||||
internal/ipc/ control-socket protocol shared by daemon and CLI/TUI
|
internal/ipc/ control-socket protocol shared by daemon and CLI/TUI
|
||||||
internal/tui/ Bubble Tea configurator (grid editor, mouse, quality)
|
internal/tui/ legacy Bubble Tea configurator (`tui --legacy`)
|
||||||
|
tui/ opentui configurator (TypeScript/Bun) — see tui/README.md
|
||||||
deploy/ sway kiosk config, autologin, install.sh
|
deploy/ sway kiosk config, autologin, install.sh
|
||||||
```
|
```
|
||||||
|
|||||||
257
cmd/rtsp-streamer/bridge.go
Normal file
257
cmd/rtsp-streamer/bridge.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
// JSON bridge between the Go core and the opentui configurator in tui/.
|
||||||
|
//
|
||||||
|
// The TypeScript TUI owns presentation and editing; every piece of domain
|
||||||
|
// logic — the config schema, validation, atomic save, Protect discovery, the
|
||||||
|
// daemon reload handshake — stays here. The bridge is deliberately three
|
||||||
|
// commands with a stable JSON contract:
|
||||||
|
//
|
||||||
|
// config export → the current config, for the TUI to edit
|
||||||
|
// config apply (stdin) → merge the edited fields back, validate, save, reload
|
||||||
|
// discover --json → Protect discovery results, without touching disk
|
||||||
|
//
|
||||||
|
// Two properties are worth preserving if this contract ever changes:
|
||||||
|
//
|
||||||
|
// - The controller password never crosses the bridge. It is `json:"-"` on the
|
||||||
|
// way out, and `apply` merges only the fields the TUI edits, so it cannot
|
||||||
|
// be clobbered on the way back in either.
|
||||||
|
// - `apply` re-reads the config from disk before merging. A TUI session left
|
||||||
|
// open for an hour can no longer overwrite a `views import`, a
|
||||||
|
// `layout set`, or a hand edit made in the meantime — it only replaces the
|
||||||
|
// keys it actually owns.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportDoc is what `config export` emits: the config plus the few constants
|
||||||
|
// the TUI would otherwise have to hardcode and keep in sync with Go.
|
||||||
|
type exportDoc struct {
|
||||||
|
ConfigPath string `json:"config_path"`
|
||||||
|
MaxTiles int `json:"max_tiles"`
|
||||||
|
MaxGridDim int `json:"max_grid_dim"`
|
||||||
|
Qualities []string `json:"qualities"`
|
||||||
|
DaemonRunning bool `json:"daemon_running"`
|
||||||
|
Config *config.Config `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDoc is the subset of the config the TUI is allowed to write. Pointer
|
||||||
|
// fields distinguish "absent" from "explicitly empty", so a payload that omits
|
||||||
|
// a key leaves the on-disk value alone rather than wiping it.
|
||||||
|
type applyDoc struct {
|
||||||
|
Cameras *[]config.Camera `json:"cameras"`
|
||||||
|
Layouts *[]config.Layout `json:"layouts"`
|
||||||
|
ActiveLayout *string `json:"active_layout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyResult reports the outcome of a save. Reloaded is best-effort: a
|
||||||
|
// missing daemon is normal (nothing is running yet) and not an error.
|
||||||
|
type applyResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Reloaded bool `json:"reloaded"`
|
||||||
|
ReloadError string `json:"reload_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoveredCamera is one camera from Protect, with its RTSP-enabled channels
|
||||||
|
// already resolved to full stream URLs. Merging into the catalog is the TUI's
|
||||||
|
// job, so it can show what changed before anything is written.
|
||||||
|
type discoveredCamera struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Streams map[string]string `json:"streams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type discoverDoc struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Cameras []discoveredCamera `json:"cameras"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitJSON writes v as indented JSON on stdout. Every bridge command prints a
|
||||||
|
// JSON document on stdout whether it succeeded or failed, so the TUI can parse
|
||||||
|
// one shape and read `ok`; the exit code carries the same signal for shell use.
|
||||||
|
func emitJSON(v any) error {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// configExportCmd prints the current config as JSON for the opentui TUI.
|
||||||
|
func configExportCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "export",
|
||||||
|
Short: "Print the config as JSON (used by the opentui configurator)",
|
||||||
|
Long: "Print the config as JSON, with the controller password omitted.\n" +
|
||||||
|
"This is the read half of the bridge the tui/ configurator uses.",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A reachable control socket means a reload on save will land, so
|
||||||
|
// the TUI can say "saved and reloaded" rather than guessing.
|
||||||
|
_, ipcErr := ipc.Send(ipc.Request{Cmd: "status"})
|
||||||
|
return emitJSON(exportDoc{
|
||||||
|
ConfigPath: cfgPath,
|
||||||
|
MaxTiles: config.MaxTiles,
|
||||||
|
MaxGridDim: config.MaxGridDim,
|
||||||
|
Qualities: config.Qualities,
|
||||||
|
DaemonRunning: ipcErr == nil,
|
||||||
|
Config: cfg,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configApplyCmd merges an edited camera/layout set from stdin into the config
|
||||||
|
// on disk, then asks a running daemon to reload.
|
||||||
|
func configApplyCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "apply",
|
||||||
|
Short: "Merge cameras/layouts/active_layout from stdin JSON, save, and reload",
|
||||||
|
Long: "Read a JSON object with any of \"cameras\", \"layouts\" and \"active_layout\"\n" +
|
||||||
|
"from stdin, merge it into the config on disk, validate, save atomically,\n" +
|
||||||
|
"and signal a running daemon to reload. Keys that are absent are left as\n" +
|
||||||
|
"they are on disk; the controller section is never touched.",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
raw, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var doc applyDoc
|
||||||
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||||
|
_ = emitJSON(applyResult{OK: false, Error: fmt.Sprintf("parsing stdin: %v", err)})
|
||||||
|
return errQuiet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read from disk so we merge onto current state, not onto
|
||||||
|
// whatever the TUI loaded when it started.
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
_ = emitJSON(applyResult{OK: false, Error: err.Error()})
|
||||||
|
return errQuiet
|
||||||
|
}
|
||||||
|
if doc.Cameras != nil {
|
||||||
|
cfg.Cameras = *doc.Cameras
|
||||||
|
}
|
||||||
|
if doc.Layouts != nil {
|
||||||
|
cfg.Layouts = *doc.Layouts
|
||||||
|
}
|
||||||
|
if doc.ActiveLayout != nil {
|
||||||
|
cfg.ActiveLayout = *doc.ActiveLayout
|
||||||
|
}
|
||||||
|
cfg.Defaults()
|
||||||
|
|
||||||
|
if err := config.Save(cfgPath, cfg); err != nil {
|
||||||
|
_ = emitJSON(applyResult{OK: false, Error: err.Error()})
|
||||||
|
return errQuiet
|
||||||
|
}
|
||||||
|
|
||||||
|
res := applyResult{OK: true, Path: cfgPath}
|
||||||
|
if _, err := ipc.Send(ipc.Request{Cmd: "reload"}); err != nil {
|
||||||
|
res.ReloadError = err.Error()
|
||||||
|
} else {
|
||||||
|
res.Reloaded = true
|
||||||
|
}
|
||||||
|
return emitJSON(res)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverJSON runs Protect discovery and prints the result without writing
|
||||||
|
// the config. The TUI merges the cameras itself and saves via `config apply`,
|
||||||
|
// so discovery stays previewable and undoable.
|
||||||
|
func discoverJSON(enableRTSP string) error {
|
||||||
|
// Always emit a well-formed camera list, even on failure, so the caller can
|
||||||
|
// read the document without special-casing a null.
|
||||||
|
fail := func(err error) error {
|
||||||
|
_ = emitJSON(discoverDoc{OK: false, Error: err.Error(), Cameras: []discoveredCamera{}})
|
||||||
|
return errQuiet
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
cl, ctx, err := loginClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
cams, err := cl.Cameras(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var want []string
|
||||||
|
for _, q := range strings.Split(enableRTSP, ",") {
|
||||||
|
if q = strings.TrimSpace(strings.ToLower(q)); q != "" {
|
||||||
|
want = append(want, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := discoverDoc{OK: true, Cameras: []discoveredCamera{}}
|
||||||
|
for i := range cams {
|
||||||
|
cam := &cams[i]
|
||||||
|
for _, q := range want {
|
||||||
|
target := cam.ChannelByPreference(q)
|
||||||
|
if target == nil || (target.RTSPEnabled && target.RTSPAlias != "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
doc.Warnings = append(doc.Warnings,
|
||||||
|
fmt.Sprintf("%s: enabling %q failed: %v", cam.Name, q, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target.RTSPEnabled, target.RTSPAlias = true, alias
|
||||||
|
doc.Enabled++
|
||||||
|
}
|
||||||
|
streams := map[string]string{}
|
||||||
|
for _, ch := range cam.Channels {
|
||||||
|
if ch.RTSPEnabled && ch.RTSPAlias != "" {
|
||||||
|
streams[strings.ToLower(ch.Name)] = cl.StreamURL(ch.RTSPAlias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(streams) == 0 {
|
||||||
|
doc.Warnings = append(doc.Warnings,
|
||||||
|
fmt.Sprintf("%s: no RTSP-enabled channel (try enabling high+low)", cam.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
doc.Cameras = append(doc.Cameras, discoveredCamera{
|
||||||
|
ID: cam.ID, Name: cam.Name, Streams: streams,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return emitJSON(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errQuiet signals "the failure is already reported as JSON on stdout, exit
|
||||||
|
// non-zero without printing a second Go-style error line".
|
||||||
|
var errQuiet = quietError{}
|
||||||
|
|
||||||
|
type quietError struct{}
|
||||||
|
|
||||||
|
func (quietError) Error() string { return "" }
|
||||||
|
|
||||||
|
// runTUI launches the opentui configurator, replacing the Go/Bubble Tea one.
|
||||||
|
// It is a separate executable (a Bun-compiled binary) because opentui is a
|
||||||
|
// TypeScript library; see tui/README.md.
|
||||||
|
func runTUI(ctx context.Context) error {
|
||||||
|
bin, err := findTUIBinary()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return execTUI(ctx, bin, cfgPath)
|
||||||
|
}
|
||||||
@@ -59,10 +59,16 @@ func daemonCmd() *cobra.Command {
|
|||||||
// discoverCmd queries UniFi Protect and merges cameras into the config.
|
// discoverCmd queries UniFi Protect and merges cameras into the config.
|
||||||
func discoverCmd() *cobra.Command {
|
func discoverCmd() *cobra.Command {
|
||||||
var enableRTSP string
|
var enableRTSP string
|
||||||
|
var asJSON bool
|
||||||
c := &cobra.Command{
|
c := &cobra.Command{
|
||||||
Use: "discover",
|
Use: "discover",
|
||||||
Short: "Discover cameras from UniFi Protect and update the config",
|
Short: "Discover cameras from UniFi Protect and update the config",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
// --json reports what discovery found and writes nothing, so the
|
||||||
|
// opentui configurator can preview the merge before saving.
|
||||||
|
if asJSON {
|
||||||
|
return discoverJSON(enableRTSP)
|
||||||
|
}
|
||||||
cfg, err := config.Load(cfgPath)
|
cfg, err := config.Load(cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -148,6 +154,7 @@ func discoverCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "comma-separated channels to enable in Protect and record, e.g. high,low")
|
c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "comma-separated channels to enable in Protect and record, e.g. high,low")
|
||||||
|
c.Flags().BoolVar(&asJSON, "json", false, "print results as JSON without saving the config")
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,16 +472,37 @@ func statusCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// tuiCmd launches the interactive configurator.
|
// tuiCmd launches the interactive configurator: the opentui one by default,
|
||||||
|
// or the previous Bubble Tea implementation with --legacy. The legacy path is
|
||||||
|
// kept because on a headless Pi the TUI is the only config UI there is, and it
|
||||||
|
// needs no Bun runtime — useful if the compiled TUI is missing or misbehaves.
|
||||||
func tuiCmd() *cobra.Command {
|
func tuiCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
var legacy bool
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "tui",
|
Use: "tui",
|
||||||
Aliases: []string{"config-ui"},
|
Aliases: []string{"config-ui"},
|
||||||
Short: "Interactive configurator (great over SSH)",
|
Short: "Interactive configurator (great over SSH)",
|
||||||
|
Long: `Interactive configurator: browse cameras, place them into layout slots, pick
|
||||||
|
the active layout, discover cameras from UniFi Protect, and save — which
|
||||||
|
signals a running daemon to reload.
|
||||||
|
|
||||||
|
This runs the opentui configurator, a separate binary (rtsp-streamer-tui) built
|
||||||
|
with Bun because opentui is a TypeScript library. It is looked for at
|
||||||
|
$RTSP_STREAMER_TUI, then next to this executable, then on $PATH. Build it with
|
||||||
|
'make tui' and install it with 'make install'.
|
||||||
|
|
||||||
|
--legacy runs the previous Bubble Tea configurator instead. It is compiled into
|
||||||
|
this binary, so it needs no Bun runtime and always works — useful if the
|
||||||
|
configurator is missing or misbehaving.`,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return tui.Run(cfgPath)
|
if legacy {
|
||||||
|
return tui.Run(cfgPath)
|
||||||
|
}
|
||||||
|
return runTUI(cmd.Context())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
c.Flags().BoolVar(&legacy, "legacy", false, "use the previous Bubble Tea configurator")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// configCmd holds config-file utilities.
|
// configCmd holds config-file utilities.
|
||||||
@@ -514,7 +542,7 @@ func configCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
c.AddCommand(initCmd, pathCmd, editCmd)
|
c.AddCommand(initCmd, pathCmd, editCmd, configExportCmd(), configApplyCmd())
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -52,7 +53,11 @@ func main() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "error:", err)
|
// Bridge commands report their failure as JSON on stdout and return
|
||||||
|
// errQuiet, so we exit non-zero without a second error line.
|
||||||
|
if !errors.Is(err, errQuiet) {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
cmd/rtsp-streamer/tuilaunch.go
Normal file
77
cmd/rtsp-streamer/tuilaunch.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tuiBinaryName is the compiled opentui configurator (see tui/ and `make tui`).
|
||||||
|
const tuiBinaryName = "rtsp-streamer-tui"
|
||||||
|
|
||||||
|
// findTUIBinary locates the opentui configurator. It is a sibling binary rather
|
||||||
|
// than something embedded in this one, because opentui is a TypeScript library
|
||||||
|
// compiled by Bun.
|
||||||
|
//
|
||||||
|
// Search order, most specific first:
|
||||||
|
//
|
||||||
|
// 1. $RTSP_STREAMER_TUI — an explicit path, for development
|
||||||
|
// 2. next to this executable — how `make install` lays it out
|
||||||
|
// 3. $PATH
|
||||||
|
func findTUIBinary() (string, error) {
|
||||||
|
if p := os.Getenv("RTSP_STREAMER_TUI"); p != "" {
|
||||||
|
if isExecutableFile(p) {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("RTSP_STREAMER_TUI=%s is not an executable file", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||||
|
exe = resolved
|
||||||
|
}
|
||||||
|
if cand := filepath.Join(filepath.Dir(exe), tuiBinaryName); isExecutableFile(cand) {
|
||||||
|
return cand, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p, err := exec.LookPath(tuiBinaryName); err == nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%s not found — build it with `make tui` (needs Bun) and install it with\n"+
|
||||||
|
"`make install`, or point $RTSP_STREAMER_TUI at it.\n"+
|
||||||
|
"To use the previous Bubble Tea configurator instead, run: rtsp-streamer tui --legacy",
|
||||||
|
tuiBinaryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExecutableFile(path string) bool {
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
return err == nil && !st.IsDir() && st.Mode()&0o111 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// execTUI runs the configurator with this process's terminal, so it can take
|
||||||
|
// raw mode and the alternate screen directly. Its exit status becomes ours.
|
||||||
|
func execTUI(ctx context.Context, bin, cfg string) error {
|
||||||
|
c := exec.CommandContext(ctx, bin, "--config", cfg)
|
||||||
|
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||||
|
// The TUI shells back to this binary for the bridge commands; tell it which
|
||||||
|
// one, so a locally-built binary is not shadowed by an installed one.
|
||||||
|
env := append(os.Environ(), "RTSP_STREAMER_CONFIG="+cfg)
|
||||||
|
if self, err := os.Executable(); err == nil {
|
||||||
|
env = append(env, "RTSP_STREAMER_BIN="+self)
|
||||||
|
}
|
||||||
|
c.Env = env
|
||||||
|
|
||||||
|
err := c.Run()
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
// The TUI already reported whatever went wrong on its own terminal.
|
||||||
|
os.Exit(exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -26,7 +26,11 @@ player:
|
|||||||
# interval (staggered) so latency can't slowly
|
# interval (staggered) so latency can't slowly
|
||||||
# drift; 0 = off. 600 keeps drift to a few sec.
|
# drift; 0 = off. 600 keeps drift to a few sec.
|
||||||
restart_backoff_seconds: 3
|
restart_backoff_seconds: 3
|
||||||
extra_args: []
|
extra_args: [] # appended verbatim to every stream's mpv.
|
||||||
|
# ["--panscan=1.0"] crops each feed to fill its
|
||||||
|
# tile instead of letterboxing it — useful when
|
||||||
|
# the grid's cells aren't 16:9, at the cost of the
|
||||||
|
# top/bottom of the field of view.
|
||||||
|
|
||||||
# Camera catalog. Normally populated by `rtsp-streamer discover`; shown here
|
# Camera catalog. Normally populated by `rtsp-streamer discover`; shown here
|
||||||
# by hand for illustration. Each camera can carry multiple stream qualities
|
# by hand for illustration. Each camera can carry multiple stream qualities
|
||||||
@@ -51,7 +55,7 @@ cameras:
|
|||||||
# Layouts place cameras on a base grid (COLSxROWS). Cameras are "tiles" that
|
# Layouts place cameras on a base grid (COLSxROWS). Cameras are "tiles" that
|
||||||
# may span multiple cells, so you can mix a big main view with small side
|
# may span multiple cells, so you can mix a big main view with small side
|
||||||
# tiles. Up to 16 cameras per layout. Edit these interactively with
|
# tiles. Up to 16 cameras per layout. Edit these interactively with
|
||||||
# `rtsp-streamer tui` (a live ASCII preview shows the arrangement).
|
# `rtsp-streamer tui` (a live preview shows the arrangement as you edit).
|
||||||
layouts:
|
layouts:
|
||||||
# Simple even grid: four 1x1 tiles on a 2x2.
|
# Simple even grid: four 1x1 tiles on a 2x2.
|
||||||
- name: quad
|
- name: quad
|
||||||
@@ -94,10 +98,17 @@ clock:
|
|||||||
format: "15:04:05" # Go time layout (24-hour w/ seconds). Others:
|
format: "15:04:05" # Go time layout (24-hour w/ seconds). Others:
|
||||||
# "3:04:05 PM" · "Mon Jan 2 15:04"
|
# "3:04:05 PM" · "Mon Jan 2 15:04"
|
||||||
corner: bottom-right # bottom-right | bottom-left | top-right | top-left
|
corner: bottom-right # bottom-right | bottom-left | top-right | top-left
|
||||||
|
# | bottom-center | top-center
|
||||||
|
# the *-center positions center horizontally and
|
||||||
|
# ignore margin on that axis
|
||||||
font_size: 44 # glyph height in px
|
font_size: 44 # glyph height in px
|
||||||
width: 300 # overlay window size in px
|
width: 300 # overlay window size in px
|
||||||
height: 72
|
height: 72
|
||||||
margin: 24 # gap from the screen edges in px
|
margin: 24 # gap from the screen edges in px
|
||||||
|
background_opacity: 0.45 # alpha of the backing box behind the text.
|
||||||
|
# MUST be > 0: on a fully transparent canvas the
|
||||||
|
# glyphs inherit alpha 0 and nothing is drawn at
|
||||||
|
# all. 0/absent is clamped to this default.
|
||||||
|
|
||||||
# Re-sync the active layout from its linked UniFi Protect live view every N
|
# 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:`
|
# seconds (0 = off). Layouts created by `views import` carry a `protect_view:`
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ set -euo pipefail
|
|||||||
KIOSK_USER="${1:-kiosk}"
|
KIOSK_USER="${1:-kiosk}"
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
BIN_SRC="${REPO_DIR}/bin/rtsp-streamer"
|
BIN_SRC="${REPO_DIR}/bin/rtsp-streamer"
|
||||||
|
# The configurator is a separate, optional binary (built with Bun; see
|
||||||
|
# tui/README.md). Accept either the host build or the arm64 cross-build.
|
||||||
|
TUI_SRC="${REPO_DIR}/bin/rtsp-streamer-tui"
|
||||||
|
[ -x "$TUI_SRC" ] || TUI_SRC="${REPO_DIR}/bin/rtsp-streamer-tui-arm64"
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
echo "error: run as root (sudo)" >&2
|
echo "error: run as root (sudo)" >&2
|
||||||
@@ -33,6 +37,16 @@ fi
|
|||||||
echo ">> installing binary to /usr/local/bin/rtsp-streamer"
|
echo ">> installing binary to /usr/local/bin/rtsp-streamer"
|
||||||
install -m 0755 "$BIN_SRC" /usr/local/bin/rtsp-streamer
|
install -m 0755 "$BIN_SRC" /usr/local/bin/rtsp-streamer
|
||||||
|
|
||||||
|
# Installed under the un-suffixed name, which is what `rtsp-streamer tui` looks
|
||||||
|
# for next to itself.
|
||||||
|
if [ -x "$TUI_SRC" ]; then
|
||||||
|
echo ">> installing configurator to /usr/local/bin/rtsp-streamer-tui"
|
||||||
|
install -m 0755 "$TUI_SRC" /usr/local/bin/rtsp-streamer-tui
|
||||||
|
else
|
||||||
|
echo ">> no configurator binary found (build it with 'make tui'); "
|
||||||
|
echo " 'rtsp-streamer tui --legacy' will still work."
|
||||||
|
fi
|
||||||
|
|
||||||
echo ">> installing sway kiosk config to /etc/rtsp-streamer/sway/config"
|
echo ">> installing sway kiosk config to /etc/rtsp-streamer/sway/config"
|
||||||
install -d /etc/rtsp-streamer/sway
|
install -d /etc/rtsp-streamer/sway
|
||||||
install -m 0644 "${REPO_DIR}/deploy/sway/config" /etc/rtsp-streamer/sway/config
|
install -m 0644 "${REPO_DIR}/deploy/sway/config" /etc/rtsp-streamer/sway/config
|
||||||
@@ -73,7 +87,7 @@ cat <<EOF
|
|||||||
Done. Next steps:
|
Done. Next steps:
|
||||||
1. Put a config at ${HOME_DIR}/.config/rtsp-streamer/config.yaml
|
1. Put a config at ${HOME_DIR}/.config/rtsp-streamer/config.yaml
|
||||||
(as ${KIOSK_USER}: rtsp-streamer config init then edit it, or run
|
(as ${KIOSK_USER}: rtsp-streamer config init then edit it, or run
|
||||||
rtsp-streamer tui).
|
rtsp-streamer tui — add --legacy if the configurator wasn't installed).
|
||||||
2. Export the controller password for the daemon. Easiest: create
|
2. Export the controller password for the daemon. Easiest: create
|
||||||
${HOME_DIR}/.config/rtsp-streamer/env with RTSP_STREAMER_PASSWORD=...
|
${HOME_DIR}/.config/rtsp-streamer/env with RTSP_STREAMER_PASSWORD=...
|
||||||
and source it from ~/.bash_profile before sway starts.
|
and source it from ~/.bash_profile before sway starts.
|
||||||
|
|||||||
@@ -192,6 +192,15 @@ func (c *Client) PlaceAndRaise(ctx context.Context, pid int, r Rect) error {
|
|||||||
return c.run(ctx, cmd)
|
return c.run(ctx, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raise brings pid's window above the other floating windows without touching
|
||||||
|
// its geometry. Re-issuing `resize set` on a correctly-sized window makes sway
|
||||||
|
// send a configure event, which makes mpv reallocate its buffers and blank for
|
||||||
|
// a frame — visible as a periodic flash on a small overlay like the clock. So
|
||||||
|
// callers that only need z-order must use this, not PlaceAndRaise.
|
||||||
|
func (c *Client) Raise(ctx context.Context, pid int) error {
|
||||||
|
return c.run(ctx, fmt.Sprintf("[pid=%d] focus", pid))
|
||||||
|
}
|
||||||
|
|
||||||
// PrepareForMPV installs a global rule so every mpv window maps floating,
|
// PrepareForMPV installs a global rule so every mpv window maps floating,
|
||||||
// ready for the daemon to position. Borders are already off via the kiosk
|
// 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
|
// config's `default_border none`, so this rule is a single command: sway's
|
||||||
|
|||||||
@@ -14,36 +14,40 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Config is the root document persisted to disk.
|
// Config is the root document persisted to disk.
|
||||||
|
//
|
||||||
|
// The json tags mirror the yaml ones so the config can round-trip through the
|
||||||
|
// `config export` / `config apply` bridge (used by the opentui configurator in
|
||||||
|
// tui/) with exactly the same key names it has on disk.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Controller describes how to reach the UniFi Protect controller for
|
// Controller describes how to reach the UniFi Protect controller for
|
||||||
// camera discovery. Optional if you only use manually-added cameras.
|
// camera discovery. Optional if you only use manually-added cameras.
|
||||||
Controller Controller `yaml:"controller"`
|
Controller Controller `yaml:"controller" json:"controller"`
|
||||||
|
|
||||||
// Display pins the output resolution used for grid geometry math. When
|
// Display pins the output resolution used for grid geometry math. When
|
||||||
// zero, the daemon asks the compositor for the connected output's mode.
|
// zero, the daemon asks the compositor for the connected output's mode.
|
||||||
Display Display `yaml:"display"`
|
Display Display `yaml:"display" json:"display"`
|
||||||
|
|
||||||
// Player holds mpv tuning shared by every stream.
|
// Player holds mpv tuning shared by every stream.
|
||||||
Player Player `yaml:"player"`
|
Player Player `yaml:"player" json:"player"`
|
||||||
|
|
||||||
// Cameras is the discovered/known camera catalog. Populated by
|
// Cameras is the discovered/known camera catalog. Populated by
|
||||||
// `rtsp-streamer discover` or edited by hand. Layouts reference cameras
|
// `rtsp-streamer discover` or edited by hand. Layouts reference cameras
|
||||||
// by Name.
|
// by Name.
|
||||||
Cameras []Camera `yaml:"cameras"`
|
Cameras []Camera `yaml:"cameras" json:"cameras"`
|
||||||
|
|
||||||
// Layouts are named preset grids.
|
// Layouts are named preset grids.
|
||||||
Layouts []Layout `yaml:"layouts"`
|
Layouts []Layout `yaml:"layouts" json:"layouts"`
|
||||||
|
|
||||||
// ActiveLayout is the name of the layout the daemon renders.
|
// ActiveLayout is the name of the layout the daemon renders.
|
||||||
ActiveLayout string `yaml:"active_layout"`
|
ActiveLayout string `yaml:"active_layout" json:"active_layout"`
|
||||||
|
|
||||||
// ViewRefreshSeconds, when > 0, makes the daemon periodically re-sync the
|
// ViewRefreshSeconds, when > 0, makes the daemon periodically re-sync the
|
||||||
// active layout from its linked UniFi Protect live view (ProtectView).
|
// active layout from its linked UniFi Protect live view (ProtectView).
|
||||||
// Requires controller credentials available to the daemon. 0 = off.
|
// Requires controller credentials available to the daemon. 0 = off.
|
||||||
ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty"`
|
ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty" json:"view_refresh_seconds,omitempty"`
|
||||||
|
|
||||||
// Clock overlays a live clock in a screen corner.
|
// Clock overlays a live clock in a screen corner.
|
||||||
Clock Clock `yaml:"clock,omitempty"`
|
Clock Clock `yaml:"clock,omitempty" json:"clock"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clock configures the on-screen clock overlay: a small always-on-top window
|
// Clock configures the on-screen clock overlay: a small always-on-top window
|
||||||
@@ -51,39 +55,54 @@ type Config struct {
|
|||||||
// so it stays legible on both bright (day) and dark (night) scenes.
|
// so it stays legible on both bright (day) and dark (night) scenes.
|
||||||
type Clock struct {
|
type Clock struct {
|
||||||
// Enabled turns the overlay on.
|
// Enabled turns the overlay on.
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
// Timezone is an IANA name (e.g. "America/Denver"); "Local" or empty uses
|
// Timezone is an IANA name (e.g. "America/Denver"); "Local" or empty uses
|
||||||
// the system timezone. DST is handled automatically.
|
// the system timezone. DST is handled automatically.
|
||||||
Timezone string `yaml:"timezone,omitempty"`
|
Timezone string `yaml:"timezone,omitempty" json:"timezone,omitempty"`
|
||||||
// Format is a Go time layout. Default "15:04:05" (24-hour with seconds).
|
// Format is a Go time layout. Default "15:04:05" (24-hour with seconds).
|
||||||
// Examples: "3:04:05 PM", "Mon Jan 2 15:04".
|
// Examples: "3:04:05 PM", "Mon Jan 2 15:04".
|
||||||
Format string `yaml:"format,omitempty"`
|
Format string `yaml:"format,omitempty" json:"format,omitempty"`
|
||||||
// Corner places the overlay: bottom-right (default), bottom-left,
|
// Corner places the overlay: bottom-right (default), bottom-left,
|
||||||
// top-right, top-left.
|
// top-right, top-left, bottom-center, top-center. The *-center positions
|
||||||
Corner string `yaml:"corner,omitempty"`
|
// center the overlay horizontally and ignore Margin on that axis.
|
||||||
|
Corner string `yaml:"corner,omitempty" json:"corner,omitempty"`
|
||||||
// FontSize is the glyph height in pixels (default 44).
|
// FontSize is the glyph height in pixels (default 44).
|
||||||
FontSize int `yaml:"font_size,omitempty"`
|
FontSize int `yaml:"font_size,omitempty" json:"font_size,omitempty"`
|
||||||
// Width/Height are the overlay window size in pixels (defaults 300x72).
|
// Width/Height are the overlay window size in pixels (defaults 300x72).
|
||||||
Width int `yaml:"width,omitempty"`
|
Width int `yaml:"width,omitempty" json:"width,omitempty"`
|
||||||
Height int `yaml:"height,omitempty"`
|
Height int `yaml:"height,omitempty" json:"height,omitempty"`
|
||||||
// Margin is the gap from the screen edges in pixels (default 24).
|
// Margin is the gap from the screen edges in pixels (default 24).
|
||||||
Margin int `yaml:"margin,omitempty"`
|
Margin int `yaml:"margin,omitempty" json:"margin,omitempty"`
|
||||||
|
// BackgroundOpacity is the alpha of the overlay's backing box, 0.0
|
||||||
|
// (invisible) to 1.0 (solid black). Default 0.45.
|
||||||
|
//
|
||||||
|
// It must not be 0: the time is drawn into the canvas by a drawtext filter,
|
||||||
|
// and on a fully transparent canvas the glyphs inherit the canvas's zero
|
||||||
|
// alpha, so the compositor draws nothing and the clock silently disappears.
|
||||||
|
// A small non-zero value gives the text a dark backing that also keeps it
|
||||||
|
// legible over bright daytime scenes.
|
||||||
|
BackgroundOpacity float64 `yaml:"background_opacity,omitempty" json:"background_opacity,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Controller holds UniFi Protect connection details.
|
// Controller holds UniFi Protect connection details.
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
Host string `yaml:"host"` // hostname or IP of the UniFi OS console
|
Host string `yaml:"host" json:"host"` // hostname or IP of the UniFi OS console
|
||||||
Username string `yaml:"username"` // local Protect user with camera access
|
Username string `yaml:"username" json:"username"` // local Protect user with camera access
|
||||||
// Password is read here only if PasswordEnv is empty. Prefer PasswordEnv
|
// Password is read here only if PasswordEnv is empty. Prefer PasswordEnv
|
||||||
// so secrets stay out of the committed config file.
|
// so secrets stay out of the committed config file.
|
||||||
Password string `yaml:"password,omitempty"`
|
//
|
||||||
PasswordEnv string `yaml:"password_env,omitempty"`
|
// json:"-" keeps the plaintext password out of `config export`: the
|
||||||
|
// opentui configurator never needs it (discovery runs in-process here) and
|
||||||
|
// `config apply` only merges the fields the TUI actually edits, so the
|
||||||
|
// secret never crosses the bridge in either direction.
|
||||||
|
Password string `yaml:"password,omitempty" json:"-"`
|
||||||
|
PasswordEnv string `yaml:"password_env,omitempty" json:"password_env,omitempty"`
|
||||||
// VerifyTLS toggles certificate verification. UniFi consoles ship a
|
// VerifyTLS toggles certificate verification. UniFi consoles ship a
|
||||||
// self-signed cert by default, so this is false unless you install a
|
// self-signed cert by default, so this is false unless you install a
|
||||||
// trusted cert.
|
// trusted cert.
|
||||||
VerifyTLS bool `yaml:"verify_tls"`
|
VerifyTLS bool `yaml:"verify_tls" json:"verify_tls"`
|
||||||
// RTSPPort is the Protect RTSPS port (7441 on current firmware).
|
// RTSPPort is the Protect RTSPS port (7441 on current firmware).
|
||||||
RTSPPort int `yaml:"rtsp_port,omitempty"`
|
RTSPPort int `yaml:"rtsp_port,omitempty" json:"rtsp_port,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvePassword returns the effective password, preferring the env var.
|
// ResolvePassword returns the effective password, preferring the env var.
|
||||||
@@ -98,54 +117,54 @@ func (c Controller) ResolvePassword() string {
|
|||||||
|
|
||||||
// Display pins the render resolution.
|
// Display pins the render resolution.
|
||||||
type Display struct {
|
type Display struct {
|
||||||
Width int `yaml:"width,omitempty"`
|
Width int `yaml:"width,omitempty" json:"width,omitempty"`
|
||||||
Height int `yaml:"height,omitempty"`
|
Height int `yaml:"height,omitempty" json:"height,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player is shared mpv configuration.
|
// Player is shared mpv configuration.
|
||||||
type Player struct {
|
type Player struct {
|
||||||
// HWDec selects mpv's hardware decoder (e.g. "auto-safe", "v4l2m2m",
|
// HWDec selects mpv's hardware decoder (e.g. "auto-safe", "v4l2m2m",
|
||||||
// "drm", "no"). "auto-safe" is a good default on the Pi 4.
|
// "drm", "no"). "auto-safe" is a good default on the Pi 4.
|
||||||
HWDec string `yaml:"hwdec"`
|
HWDec string `yaml:"hwdec" json:"hwdec"`
|
||||||
// Profile applies an mpv profile; "low-latency" trims buffering for live
|
// Profile applies an mpv profile; "low-latency" trims buffering for live
|
||||||
// feeds. Empty disables it.
|
// feeds. Empty disables it.
|
||||||
Profile string `yaml:"profile"`
|
Profile string `yaml:"profile" json:"profile"`
|
||||||
// ExtraArgs are appended verbatim to every mpv invocation.
|
// ExtraArgs are appended verbatim to every mpv invocation.
|
||||||
ExtraArgs []string `yaml:"extra_args,omitempty"`
|
ExtraArgs []string `yaml:"extra_args,omitempty" json:"extra_args,omitempty"`
|
||||||
// MaxFPS caps the rendered frame rate (mpv --vf=fps). 0 = uncapped. Trims
|
// MaxFPS caps the rendered frame rate (mpv --vf=fps). 0 = uncapped. Trims
|
||||||
// render/scale load; the bigger decode lever is using substreams.
|
// render/scale load; the bigger decode lever is using substreams.
|
||||||
MaxFPS int `yaml:"max_fps,omitempty"`
|
MaxFPS int `yaml:"max_fps,omitempty" json:"max_fps,omitempty"`
|
||||||
// Audio enables stream sound. Off by default: a wall of simultaneous
|
// Audio enables stream sound. Off by default: a wall of simultaneous
|
||||||
// feeds is unwatchable with sound, and skipping the audio decoder saves
|
// feeds is unwatchable with sound, and skipping the audio decoder saves
|
||||||
// CPU per stream.
|
// CPU per stream.
|
||||||
Audio bool `yaml:"audio,omitempty"`
|
Audio bool `yaml:"audio,omitempty" json:"audio,omitempty"`
|
||||||
// ResyncSeconds, when > 0, makes the daemon reconnect each stream to the
|
// ResyncSeconds, when > 0, makes the daemon reconnect each stream to the
|
||||||
// live edge on this interval (staggered across tiles). Live RTSP can't be
|
// live edge on this interval (staggered across tiles). Live RTSP can't be
|
||||||
// seeked, so latency that slowly accumulates when the Pi decodes a hair
|
// seeked, so latency that slowly accumulates when the Pi decodes a hair
|
||||||
// behind real-time is only cleared by reopening the stream. Each tile is
|
// behind real-time is only cleared by reopening the stream. Each tile is
|
||||||
// resynced about once per interval; e.g. 600 keeps drift well under a few
|
// resynced about once per interval; e.g. 600 keeps drift well under a few
|
||||||
// seconds. 0 = off.
|
// seconds. 0 = off.
|
||||||
ResyncSeconds int `yaml:"resync_seconds,omitempty"`
|
ResyncSeconds int `yaml:"resync_seconds,omitempty" json:"resync_seconds,omitempty"`
|
||||||
// RestartBackoffSeconds is how long to wait before relaunching a stream
|
// RestartBackoffSeconds is how long to wait before relaunching a stream
|
||||||
// that exited or stalled.
|
// that exited or stalled.
|
||||||
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"`
|
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty" json:"restart_backoff_seconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Camera is one known RTSP source.
|
// Camera is one known RTSP source.
|
||||||
type Camera struct {
|
type Camera struct {
|
||||||
// ID is the UniFi Protect camera id, when discovered. Blank for manual
|
// ID is the UniFi Protect camera id, when discovered. Blank for manual
|
||||||
// entries.
|
// entries.
|
||||||
ID string `yaml:"id,omitempty"`
|
ID string `yaml:"id,omitempty" json:"id,omitempty"`
|
||||||
// Name is the human label and the key layouts reference. Must be unique.
|
// Name is the human label and the key layouts reference. Must be unique.
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
// RTSP is a single fully-resolved stream URL. Kept for backward
|
// RTSP is a single fully-resolved stream URL. Kept for backward
|
||||||
// compatibility and manual entries; Streams takes precedence when present.
|
// compatibility and manual entries; Streams takes precedence when present.
|
||||||
RTSP string `yaml:"rtsp,omitempty"`
|
RTSP string `yaml:"rtsp,omitempty" json:"rtsp,omitempty"`
|
||||||
// Streams maps a quality ("high"|"medium"|"low") to its stream URL, so a
|
// Streams maps a quality ("high"|"medium"|"low") to its stream URL, so a
|
||||||
// tile can choose per-tile which to pull. Populated by discovery.
|
// tile can choose per-tile which to pull. Populated by discovery.
|
||||||
Streams map[string]string `yaml:"streams,omitempty"`
|
Streams map[string]string `yaml:"streams,omitempty" json:"streams,omitempty"`
|
||||||
// Disabled hides the camera from selection without deleting it.
|
// Disabled hides the camera from selection without deleting it.
|
||||||
Disabled bool `yaml:"disabled,omitempty"`
|
Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Qualities in preference order, high to low.
|
// Qualities in preference order, high to low.
|
||||||
@@ -198,36 +217,42 @@ func (c Camera) AvailableQualities() []string {
|
|||||||
// more than this on a Pi 4 is impractical even with substreams.
|
// more than this on a Pi 4 is impractical even with substreams.
|
||||||
const MaxTiles = 16
|
const MaxTiles = 16
|
||||||
|
|
||||||
|
// MaxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity;
|
||||||
|
// the number of *tiles* (cameras) is capped separately by MaxTiles. It lives
|
||||||
|
// here rather than in an editor so the CLI, the Go TUI and the opentui
|
||||||
|
// configurator all enforce the same ceiling.
|
||||||
|
const MaxGridDim = 8
|
||||||
|
|
||||||
// Layout is a named arrangement on a base grid. Cameras are placed as Tiles
|
// Layout is a named arrangement on a base grid. Cameras are placed as Tiles
|
||||||
// that may span multiple grid cells (a big main view plus small side tiles,
|
// that may span multiple grid cells (a big main view plus small side tiles,
|
||||||
// security-wall style). The older Slots form (one camera per cell, row-major)
|
// security-wall style). The older Slots form (one camera per cell, row-major)
|
||||||
// is still accepted and is transparently upgraded to tiles.
|
// is still accepted and is transparently upgraded to tiles.
|
||||||
type Layout struct {
|
type Layout struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
// Grid is the base grid "COLSxROWS", e.g. "4x3". Tiles are placed and
|
// Grid is the base grid "COLSxROWS", e.g. "4x3". Tiles are placed and
|
||||||
// sized in these cells.
|
// sized in these cells.
|
||||||
Grid string `yaml:"grid"`
|
Grid string `yaml:"grid" json:"grid"`
|
||||||
// Tiles is the placement model. Preferred over Slots.
|
// Tiles is the placement model. Preferred over Slots.
|
||||||
Tiles []Tile `yaml:"tiles,omitempty"`
|
Tiles []Tile `yaml:"tiles,omitempty" json:"tiles,omitempty"`
|
||||||
// Slots is the legacy one-camera-per-cell model (row-major). Kept for
|
// Slots is the legacy one-camera-per-cell model (row-major). Kept for
|
||||||
// backward compatibility; EffectiveTiles converts it to tiles.
|
// backward compatibility; EffectiveTiles converts it to tiles.
|
||||||
Slots []string `yaml:"slots,omitempty"`
|
Slots []string `yaml:"slots,omitempty" json:"slots,omitempty"`
|
||||||
// ProtectView, when set, is the name of the UniFi Protect live view this
|
// ProtectView, when set, is the name of the UniFi Protect live view this
|
||||||
// layout mirrors. `views import` sets it; the daemon re-syncs it on a timer
|
// layout mirrors. `views import` sets it; the daemon re-syncs it on a timer
|
||||||
// when view_refresh_seconds > 0 and controller creds are available.
|
// when view_refresh_seconds > 0 and controller creds are available.
|
||||||
ProtectView string `yaml:"protect_view,omitempty"`
|
ProtectView string `yaml:"protect_view,omitempty" json:"protect_view,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tile places one camera at a rectangular region of the base grid.
|
// Tile places one camera at a rectangular region of the base grid.
|
||||||
type Tile struct {
|
type Tile struct {
|
||||||
Camera string `yaml:"camera"`
|
Camera string `yaml:"camera" json:"camera"`
|
||||||
Col int `yaml:"col"`
|
Col int `yaml:"col" json:"col"`
|
||||||
Row int `yaml:"row"`
|
Row int `yaml:"row" json:"row"`
|
||||||
ColSpan int `yaml:"colspan,omitempty"` // defaults to 1
|
ColSpan int `yaml:"colspan,omitempty" json:"colspan,omitempty"` // defaults to 1
|
||||||
RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1
|
RowSpan int `yaml:"rowspan,omitempty" json:"rowspan,omitempty"` // defaults to 1
|
||||||
// Quality selects which stream to pull for this tile: "high"|"medium"|
|
// Quality selects which stream to pull for this tile: "high"|"medium"|
|
||||||
// "low". Empty means the camera's best available (see Camera.StreamURL).
|
// "low". Empty means the camera's best available (see Camera.StreamURL).
|
||||||
Quality string `yaml:"quality,omitempty"`
|
Quality string `yaml:"quality,omitempty" json:"quality,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Span returns the tile's spans with zero values normalized to 1.
|
// Span returns the tile's spans with zero values normalized to 1.
|
||||||
@@ -355,6 +380,14 @@ func (c *Config) Defaults() {
|
|||||||
if c.Clock.Margin == 0 {
|
if c.Clock.Margin == 0 {
|
||||||
c.Clock.Margin = 24
|
c.Clock.Margin = 24
|
||||||
}
|
}
|
||||||
|
if c.Clock.BackgroundOpacity <= 0 {
|
||||||
|
// 0 renders an invisible clock (see BackgroundOpacity), so treat
|
||||||
|
// unset — and any nonsense value — as the default.
|
||||||
|
c.Clock.BackgroundOpacity = 0.45
|
||||||
|
}
|
||||||
|
if c.Clock.BackgroundOpacity > 1 {
|
||||||
|
c.Clock.BackgroundOpacity = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,9 +440,10 @@ func (c *Config) Validate() error {
|
|||||||
|
|
||||||
if c.Clock.Enabled && c.Clock.Corner != "" {
|
if c.Clock.Enabled && c.Clock.Corner != "" {
|
||||||
switch c.Clock.Corner {
|
switch c.Clock.Corner {
|
||||||
case "bottom-right", "bottom-left", "top-right", "top-left":
|
case "bottom-right", "bottom-left", "top-right", "top-left",
|
||||||
|
"bottom-center", "top-center":
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("clock.corner %q must be one of bottom-right, bottom-left, top-right, top-left", c.Clock.Corner)
|
return fmt.Errorf("clock.corner %q must be one of bottom-right, bottom-left, top-right, top-left, bottom-center, top-center", c.Clock.Corner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ func TestClockRectCorners(t *testing.T) {
|
|||||||
"top-right": {1920 - 300 - 24, 24},
|
"top-right": {1920 - 300 - 24, 24},
|
||||||
"top-left": {24, 24},
|
"top-left": {24, 24},
|
||||||
"": {1920 - 300 - 24, 1080 - 72 - 24}, // default = bottom-right
|
"": {1920 - 300 - 24, 1080 - 72 - 24}, // default = bottom-right
|
||||||
|
// Centered positions ignore the horizontal margin and center on the
|
||||||
|
// output; the margin still applies vertically.
|
||||||
|
"bottom-center": {(1920 - 300) / 2, 1080 - 72 - 24},
|
||||||
|
"top-center": {(1920 - 300) / 2, 24},
|
||||||
}
|
}
|
||||||
for corner, want := range cases {
|
for corner, want := range cases {
|
||||||
cl.Corner = corner
|
cl.Corner = corner
|
||||||
|
|||||||
@@ -170,13 +170,29 @@ func (d *Daemon) placeClock(ctx context.Context, clock *player.Clock, rect compo
|
|||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Only re-assert geometry when it has actually drifted. Sending
|
||||||
|
// `resize set` every tick makes sway reconfigure the surface, which
|
||||||
|
// blanks mpv for a frame and reads as a flashing clock. Z-order still
|
||||||
|
// needs re-asserting every tick, because a restarted camera maps
|
||||||
|
// focused and lands above the overlay — but a bare focus does not
|
||||||
|
// touch the surface.
|
||||||
|
if rects, err := d.comp.WindowRects(ctx); err == nil {
|
||||||
|
if actual, ok := rects[pid]; ok && actual == rect {
|
||||||
|
if err := d.comp.Raise(ctx, pid); err != nil {
|
||||||
|
d.log.Debug("clock raise failed", "err", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := d.comp.PlaceAndRaise(ctx, pid, rect); err != nil {
|
if err := d.comp.PlaceAndRaise(ctx, pid, rect); err != nil {
|
||||||
d.log.Debug("clock place failed", "err", err)
|
d.log.Debug("clock place failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clockRect computes the overlay rectangle for the configured corner.
|
// clockRect computes the overlay rectangle for the configured corner. The
|
||||||
|
// *-center positions are horizontally centred on the output, which is why the
|
||||||
|
// setting is a position rather than strictly a corner.
|
||||||
func clockRect(w, h int, cl config.Clock) compositor.Rect {
|
func clockRect(w, h int, cl config.Clock) compositor.Rect {
|
||||||
m, cw, ch := cl.Margin, cl.Width, cl.Height
|
m, cw, ch := cl.Margin, cl.Width, cl.Height
|
||||||
x, y := w-cw-m, h-ch-m // bottom-right default
|
x, y := w-cw-m, h-ch-m // bottom-right default
|
||||||
@@ -187,6 +203,10 @@ func clockRect(w, h int, cl config.Clock) compositor.Rect {
|
|||||||
x, y = m, m
|
x, y = m, m
|
||||||
case "top-right":
|
case "top-right":
|
||||||
x, y = w-cw-m, m
|
x, y = w-cw-m, m
|
||||||
|
case "bottom-center":
|
||||||
|
x, y = (w-cw)/2, h-ch-m
|
||||||
|
case "top-center":
|
||||||
|
x, y = (w-cw)/2, m
|
||||||
}
|
}
|
||||||
return compositor.Rect{X: x, Y: y, W: cw, H: ch}
|
return compositor.Rect{X: x, Y: y, W: cw, H: ch}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,18 +16,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Clock is a small always-on-top mpv window that renders the current local
|
// 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
|
// time: white glyphs with a black outline, which stay legible over both bright
|
||||||
// shows through) and the time is drawn as an ASS overlay pushed over mpv's IPC
|
// (day) and dark (night) scenes without sampling the picture. The time is
|
||||||
// once a second: white glyphs with a black outline, which stay legible over
|
// formatted in Go against a fixed timezone, so it does not depend on the host
|
||||||
// both bright (day) and dark (night) scenes without sampling the picture. The
|
// clock's zone and gets DST right.
|
||||||
// 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 {
|
type Clock struct {
|
||||||
cfg config.Clock
|
cfg config.Clock
|
||||||
loc *time.Location
|
loc *time.Location
|
||||||
runDir string
|
runDir string
|
||||||
ipcPath string
|
textPath string
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
@@ -39,13 +49,35 @@ func NewClock(cfg config.Clock, runDir string, log *slog.Logger) (*Clock, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("clock timezone %q: %w", cfg.Timezone, err)
|
return nil, fmt.Errorf("clock timezone %q: %w", cfg.Timezone, err)
|
||||||
}
|
}
|
||||||
return &Clock{
|
textPath := filepath.Join(runDir, "clock-text.txt")
|
||||||
cfg: cfg,
|
// A ':' or '\' in the path would be read as filtergraph syntax and break the
|
||||||
loc: loc,
|
// drawtext option rather than pointing at the file.
|
||||||
runDir: runDir,
|
if strings.ContainsAny(textPath, `:\`) {
|
||||||
ipcPath: filepath.Join(runDir, "mpv-clock.sock"),
|
return nil, fmt.Errorf("clock text path %q contains a character that cannot be escaped in a filtergraph", textPath)
|
||||||
log: log.With("comp", "clock"),
|
}
|
||||||
}, nil
|
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.
|
// Title is the window title, so the compositor can match the clock by title.
|
||||||
@@ -62,12 +94,31 @@ func (c *Clock) PID() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Clock) args() []string {
|
func (c *Clock) args() []string {
|
||||||
// A transparent RGBA canvas at a few fps; the text is drawn via osd-overlay,
|
// A mostly-transparent RGBA canvas at a few fps; the text is drawn via
|
||||||
// so nothing needs to be escaped into the filtergraph. --alpha=yes lets the
|
// osd-overlay, so nothing needs to be escaped into the filtergraph.
|
||||||
// transparent areas composite over the camera windows behind it (if the
|
// --alpha=yes lets the canvas composite over the camera windows behind it.
|
||||||
// compositor can't do alpha, the canvas is black and the outlined white
|
//
|
||||||
// text is still perfectly readable — it just gains a dark backing).
|
// The canvas alpha must stay > 0. mpv blends the OSD into the canvas, and
|
||||||
src := fmt.Sprintf("av://lavfi:color=c=black@0.0:s=%dx%d:r=4,format=rgba", c.cfg.Width, c.cfg.Height)
|
// 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{
|
return []string{
|
||||||
"--no-config",
|
"--no-config",
|
||||||
"--force-window=yes",
|
"--force-window=yes",
|
||||||
@@ -83,15 +134,23 @@ func (c *Clock) args() []string {
|
|||||||
"--no-audio",
|
"--no-audio",
|
||||||
"--keepaspect=no",
|
"--keepaspect=no",
|
||||||
"--alpha=yes",
|
"--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(),
|
"--title=" + c.Title(),
|
||||||
"--input-ipc-server=" + c.ipcPath,
|
|
||||||
fmt.Sprintf("--geometry=%dx%d", c.cfg.Width, c.cfg.Height),
|
fmt.Sprintf("--geometry=%dx%d", c.cfg.Width, c.cfg.Height),
|
||||||
src,
|
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 {
|
func (c *Clock) start(ctx context.Context) error {
|
||||||
_ = os.Remove(c.ipcPath)
|
|
||||||
cmd := exec.CommandContext(ctx, "mpv", c.args()...)
|
cmd := exec.CommandContext(ctx, "mpv", c.args()...)
|
||||||
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
|
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
|
||||||
cmd.WaitDelay = 2 * time.Second
|
cmd.WaitDelay = 2 * time.Second
|
||||||
@@ -162,30 +221,11 @@ func (c *Clock) tick(ctx context.Context, done <-chan struct{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// push renders the current time as ASS and sends it as an OSD overlay. an5
|
// push publishes the current time for the drawtext filter to pick up on its
|
||||||
// centers it in the window; \bord gives the black outline, \1c/\3c set the
|
// next frame. Failures are logged at warn, not debug: a clock that stops
|
||||||
// white fill and black outline colors (ASS is &HBBGGRR&).
|
// updating is silently wrong, which is worse than one that is visibly absent.
|
||||||
func (c *Clock) push() {
|
func (c *Clock) push() {
|
||||||
text := assEscape(time.Now().In(c.loc).Format(c.cfg.Format))
|
if err := c.writeText(); err != nil {
|
||||||
data := fmt.Sprintf(
|
c.log.Warn("clock text update failed", "path", c.textPath, "err", err)
|
||||||
`{\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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ package player
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
"github.com/lwoodard/rtsp-streamer/internal/logbuf"
|
"github.com/lwoodard/rtsp-streamer/internal/logbuf"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,3 +34,90 @@ func TestLastLineNil(t *testing.T) {
|
|||||||
t.Errorf("lastLine(nil) = %q, want empty", got)
|
t.Errorf("lastLine(nil) = %q, want empty", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testClock(cfg config.Clock) *Clock {
|
||||||
|
return &Clock{cfg: cfg, textPath: "/run/user/1000/rtsp-streamer/clock-text.txt"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero-alpha canvas makes the drawn text inherit alpha 0, so the clock
|
||||||
|
// renders as nothing at all. Config clamps the opacity, and args() must clamp
|
||||||
|
// too rather than emitting a fully transparent canvas.
|
||||||
|
func TestClockCanvasIsNeverFullyTransparent(t *testing.T) {
|
||||||
|
for _, opacity := range []float64{0, -1, 0.45, 1} {
|
||||||
|
c := testClock(config.Clock{Width: 300, Height: 72, BackgroundOpacity: opacity})
|
||||||
|
src := c.args()[len(c.args())-1]
|
||||||
|
if strings.Contains(src, "black@0.000") {
|
||||||
|
t.Errorf("opacity %v produced a fully transparent canvas: %s", opacity, src)
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, "s=300x72") {
|
||||||
|
t.Errorf("opacity %v: size missing from %s", opacity, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configured opacity must reach the filtergraph verbatim.
|
||||||
|
func TestClockCanvasUsesConfiguredOpacity(t *testing.T) {
|
||||||
|
src := testClock(config.Clock{Width: 300, Height: 72, BackgroundOpacity: 0.6}).args()
|
||||||
|
if got := src[len(src)-1]; !strings.Contains(got, "black@0.600") {
|
||||||
|
t.Errorf("got %s, want canvas alpha 0.600", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The time must be baked into every frame by drawtext, reloading the text file
|
||||||
|
// each frame. An mpv OSD overlay only renders on the frame where its content
|
||||||
|
// changes, which showed up as a flashing clock.
|
||||||
|
func TestClockDrawsTextIntoEveryFrame(t *testing.T) {
|
||||||
|
c := testClock(config.Clock{Width: 300, Height: 72, FontSize: 44})
|
||||||
|
src := c.args()[len(c.args())-1]
|
||||||
|
for _, want := range []string{
|
||||||
|
"drawtext=textfile=" + c.textPath,
|
||||||
|
"reload=1", // re-read the file per frame, else the clock freezes
|
||||||
|
"fontsize=44", // from config, not hardcoded
|
||||||
|
"borderw=3", // outline for legibility over bright scenes
|
||||||
|
} {
|
||||||
|
if !strings.Contains(src, want) {
|
||||||
|
t.Errorf("filtergraph missing %q:\n %s", want, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawtext stamps the time when a frame is generated, so buffering ahead makes
|
||||||
|
// the visible clock lag. These flags keep generation just-in-time.
|
||||||
|
func TestClockDisablesReadahead(t *testing.T) {
|
||||||
|
args := testClock(config.Clock{Width: 300, Height: 72, FontSize: 44}).args()
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
for _, want := range []string{"--cache=no", "--demuxer-readahead-secs=0"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("args missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeText must replace the file atomically so drawtext cannot read a
|
||||||
|
// half-written time, and must honour the configured layout and zone.
|
||||||
|
func TestClockWriteTextIsAtomicAndFormatted(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
loc, err := time.LoadLocation("America/Denver")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := &Clock{
|
||||||
|
cfg: config.Clock{Format: "15:04:05"},
|
||||||
|
loc: loc,
|
||||||
|
textPath: filepath.Join(dir, "clock-text.txt"),
|
||||||
|
}
|
||||||
|
if err := c.writeText(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(c.textPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := string(b); len(got) != len("15:04:05") {
|
||||||
|
t.Errorf("wrote %q, want an HH:MM:SS-shaped time", got)
|
||||||
|
}
|
||||||
|
// The temp file used for the atomic rename must not be left behind.
|
||||||
|
if _, err := os.Stat(c.textPath + ".tmp"); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("temp file left behind: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity;
|
// maxGridDim caps each base-grid axis; see config.MaxGridDim, which the CLI
|
||||||
// the number of *tiles* (cameras) is separately capped at config.MaxTiles.
|
// and the opentui configurator share.
|
||||||
const maxGridDim = 8
|
const maxGridDim = config.MaxGridDim
|
||||||
|
|
||||||
// migrateToTiles converts a layout to the tile model in place so the grid
|
// migrateToTiles converts a layout to the tile model in place so the grid
|
||||||
// editor always works on tiles (legacy slot layouts are upgraded on open).
|
// editor always works on tiles (legacy slot layouts are upgraded on open).
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
// Package tui is the interactive Bubble Tea configurator. It is meant to be
|
// Package tui is the legacy Bubble Tea configurator, reachable as
|
||||||
// run over SSH on the headless Pi: browse cameras, assign them to layout
|
// `rtsp-streamer tui --legacy`. The default configurator is the opentui one in
|
||||||
// slots, pick the active layout, discover cameras from UniFi Protect, and save
|
// tui/ (see tui/README.md); this one is kept as a fallback because it is part of
|
||||||
// — signalling the running daemon to reload on save.
|
// the Go binary and needs no Bun runtime, and on a headless Pi the TUI is the
|
||||||
|
// only config UI there is.
|
||||||
|
//
|
||||||
|
// It is meant to be run over SSH: browse cameras, assign them to layout slots,
|
||||||
|
// pick the active layout, discover cameras from UniFi Protect, and save —
|
||||||
|
// signalling the running daemon to reload on save.
|
||||||
|
//
|
||||||
|
// New work belongs in tui/. Changes here should be limited to keeping it
|
||||||
|
// building and correct.
|
||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -31,18 +31,66 @@ func GridForSlots(n int) (cols, rows int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutFromView maps a Protect live view to a layout. Cameras are placed
|
// preset is a Protect arrangement: a base grid plus one cell placement per
|
||||||
// row-major, one per slot (the first camera of a cycling slot). Sizing uses a
|
// slot, in slot order. Camera is filled in by LayoutFromView.
|
||||||
// slot-count grid for now; asymmetric Protect presets need the `layout` int
|
type preset struct {
|
||||||
// mapping. The returned layout is linked back to the view (ProtectView) so the
|
cols, rows int
|
||||||
// daemon can re-sync it. Returns warnings for unmappable cameras.
|
cells []config.Tile
|
||||||
|
}
|
||||||
|
|
||||||
|
// presets holds the Protect arrangements that are NOT a uniform grid, keyed by
|
||||||
|
// slot count. Protect picks these presets by camera count, so a count that is
|
||||||
|
// absent here is a plain row-major grid and falls through to uniformPreset.
|
||||||
|
var presets = map[int]preset{
|
||||||
|
// Protect's 8-camera preset: four 2x2 tiles with a right-hand column of
|
||||||
|
// four 1x1 tiles. Numbers are slot order:
|
||||||
|
// 1 1 2 2 3
|
||||||
|
// 1 1 2 2 4
|
||||||
|
// 5 5 6 6 7
|
||||||
|
// 5 5 6 6 8
|
||||||
|
8: {cols: 5, rows: 4, cells: []config.Tile{
|
||||||
|
{Col: 0, Row: 0, ColSpan: 2, RowSpan: 2},
|
||||||
|
{Col: 2, Row: 0, ColSpan: 2, RowSpan: 2},
|
||||||
|
{Col: 4, Row: 0, ColSpan: 1, RowSpan: 1},
|
||||||
|
{Col: 4, Row: 1, ColSpan: 1, RowSpan: 1},
|
||||||
|
{Col: 0, Row: 2, ColSpan: 2, RowSpan: 2},
|
||||||
|
{Col: 2, Row: 2, ColSpan: 2, RowSpan: 2},
|
||||||
|
{Col: 4, Row: 2, ColSpan: 1, RowSpan: 1},
|
||||||
|
{Col: 4, Row: 3, ColSpan: 1, RowSpan: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniformPreset builds a plain row-major grid of 1x1 cells.
|
||||||
|
func uniformPreset(cols, rows int) preset {
|
||||||
|
p := preset{cols: cols, rows: rows}
|
||||||
|
for i := 0; i < cols*rows; i++ {
|
||||||
|
p.cells = append(p.cells, config.Tile{Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1})
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetForSlots returns the arrangement Protect uses for n slots.
|
||||||
|
func presetForSlots(n int) preset {
|
||||||
|
if p, ok := presets[n]; ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
cols, rows := GridForSlots(n)
|
||||||
|
return uniformPreset(cols, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LayoutFromView maps a Protect live view to a layout. Each slot's first camera
|
||||||
|
// (slots may cycle several) is placed into that slot's cell in the arrangement
|
||||||
|
// Protect uses for the slot count — see presets. The returned layout is linked
|
||||||
|
// back to the view (ProtectView) so the daemon can re-sync it. Returns warnings
|
||||||
|
// for unmappable cameras and for any arrangement that leaves the wall with
|
||||||
|
// empty cells, which would otherwise show as blank rectangles on screen.
|
||||||
func LayoutFromView(v protect.LiveView, idToName map[string]string) (config.Layout, []string) {
|
func LayoutFromView(v protect.LiveView, idToName map[string]string) (config.Layout, []string) {
|
||||||
cols, rows := GridForSlots(len(v.Slots))
|
p := presetForSlots(len(v.Slots))
|
||||||
var tiles []config.Tile
|
var tiles []config.Tile
|
||||||
var warns []string
|
var warns []string
|
||||||
for i, slot := range v.Slots {
|
for i, slot := range v.Slots {
|
||||||
if i >= cols*rows {
|
if i >= len(p.cells) {
|
||||||
warns = append(warns, fmt.Sprintf("more slots than the %dx%d grid holds; extra dropped", cols, rows))
|
warns = append(warns, fmt.Sprintf("more slots than the %dx%d grid holds; extra dropped", p.cols, p.rows))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if len(slot.Cameras) == 0 {
|
if len(slot.Cameras) == 0 {
|
||||||
@@ -50,19 +98,51 @@ func LayoutFromView(v protect.LiveView, idToName map[string]string) (config.Layo
|
|||||||
}
|
}
|
||||||
name := idToName[slot.Cameras[0]]
|
name := idToName[slot.Cameras[0]]
|
||||||
if name == "" {
|
if name == "" {
|
||||||
warns = append(warns, fmt.Sprintf("slot %d camera %s not in config", i, slot.Cameras[0]))
|
warns = append(warns, fmt.Sprintf("slot %d camera %s not in config (run `discover`)", i, slot.Cameras[0]))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tiles = append(tiles, config.Tile{Camera: name, Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1})
|
t := p.cells[i]
|
||||||
|
t.Camera = name
|
||||||
|
tiles = append(tiles, t)
|
||||||
|
}
|
||||||
|
if gaps := unfilled(p, tiles); gaps > 0 {
|
||||||
|
warns = append(warns, fmt.Sprintf("%d of %d cells in the %dx%d grid are empty and will show as blank areas",
|
||||||
|
gaps, p.cols*p.rows, p.cols, p.rows))
|
||||||
}
|
}
|
||||||
return config.Layout{
|
return config.Layout{
|
||||||
Name: Sanitize(v.Name),
|
Name: Sanitize(v.Name),
|
||||||
Grid: fmt.Sprintf("%dx%d", cols, rows),
|
Grid: fmt.Sprintf("%dx%d", p.cols, p.rows),
|
||||||
Tiles: tiles,
|
Tiles: tiles,
|
||||||
ProtectView: v.Name,
|
ProtectView: v.Name,
|
||||||
}, warns
|
}, warns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unfilled counts grid cells no tile covers. A non-zero result means the wall
|
||||||
|
// has holes: either the preset does not tile its own grid or slots were skipped.
|
||||||
|
func unfilled(p preset, tiles []config.Tile) int {
|
||||||
|
if p.cols <= 0 || p.rows <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
covered := make([]bool, p.cols*p.rows)
|
||||||
|
for _, t := range tiles {
|
||||||
|
cs, rs := t.Span()
|
||||||
|
for r := t.Row; r < t.Row+rs; r++ {
|
||||||
|
for c := t.Col; c < t.Col+cs; c++ {
|
||||||
|
if r >= 0 && r < p.rows && c >= 0 && c < p.cols {
|
||||||
|
covered[r*p.cols+c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, ok := range covered {
|
||||||
|
if !ok {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize turns a view name into a layout name (lowercase, dashed).
|
// Sanitize turns a view name into a layout name (lowercase, dashed).
|
||||||
func Sanitize(s string) string {
|
func Sanitize(s string) string {
|
||||||
s = strings.ToLower(strings.TrimSpace(s))
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
|||||||
139
internal/viewmap/viewmap_test.go
Normal file
139
internal/viewmap/viewmap_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package viewmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// view builds a live view whose slots each hold one camera id.
|
||||||
|
func view(name string, ids ...string) protect.LiveView {
|
||||||
|
v := protect.LiveView{Name: name}
|
||||||
|
for _, id := range ids {
|
||||||
|
v.Slots = append(v.Slots, protect.LiveViewSlot{Cameras: []string{id}})
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// namesFor maps ids "c0".."cN-1" to themselves so tiles are easy to assert on.
|
||||||
|
func namesFor(n int) map[string]string {
|
||||||
|
m := map[string]string{}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
m[id(i)] = id(i)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func id(i int) string { return string(rune('a' + i)) }
|
||||||
|
|
||||||
|
func ids(n int) []string {
|
||||||
|
out := make([]string, n)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = id(i)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 8-camera Protect preset is asymmetric: four 2x2 tiles plus a right-hand
|
||||||
|
// column of four 1x1 tiles, tiling a 5x4 grid exactly.
|
||||||
|
func TestLayoutFromView_EightSlotPreset(t *testing.T) {
|
||||||
|
got, warns := LayoutFromView(view("Office View", ids(8)...), namesFor(8))
|
||||||
|
|
||||||
|
if got.Grid != "5x4" {
|
||||||
|
t.Fatalf("grid = %q, want 5x4", got.Grid)
|
||||||
|
}
|
||||||
|
if len(warns) != 0 {
|
||||||
|
t.Errorf("unexpected warnings: %v", warns)
|
||||||
|
}
|
||||||
|
if got.ProtectView != "Office View" || got.Name != "office-view" {
|
||||||
|
t.Errorf("name/link = %q/%q", got.Name, got.ProtectView)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []struct{ col, row, cs, rs int }{
|
||||||
|
{0, 0, 2, 2}, {2, 0, 2, 2}, {4, 0, 1, 1}, {4, 1, 1, 1},
|
||||||
|
{0, 2, 2, 2}, {2, 2, 2, 2}, {4, 2, 1, 1}, {4, 3, 1, 1},
|
||||||
|
}
|
||||||
|
if len(got.Tiles) != len(want) {
|
||||||
|
t.Fatalf("got %d tiles, want %d", len(got.Tiles), len(want))
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
tile := got.Tiles[i]
|
||||||
|
cs, rs := tile.Span()
|
||||||
|
if tile.Col != w.col || tile.Row != w.row || cs != w.cs || rs != w.rs {
|
||||||
|
t.Errorf("tile %d = (%d,%d)+%dx%d, want (%d,%d)+%dx%d",
|
||||||
|
i, tile.Col, tile.Row, cs, rs, w.col, w.row, w.cs, w.rs)
|
||||||
|
}
|
||||||
|
if tile.Camera != id(i) {
|
||||||
|
t.Errorf("tile %d camera = %q, want %q", i, tile.Camera, id(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 8-slot preset must leave no holes; that was the original bug (8 tiles
|
||||||
|
// dropped into a 3x3 grid left the bottom-right cell blank).
|
||||||
|
func TestLayoutFromView_EightSlotsTileGridExactly(t *testing.T) {
|
||||||
|
got, _ := LayoutFromView(view("Office View", ids(8)...), namesFor(8))
|
||||||
|
if n := unfilled(presetForSlots(8), got.Tiles); n != 0 {
|
||||||
|
t.Errorf("%d cells left empty, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts without a preset keep the uniform row-major grid.
|
||||||
|
func TestLayoutFromView_UniformFallback(t *testing.T) {
|
||||||
|
got, warns := LayoutFromView(view("Default", ids(9)...), namesFor(9))
|
||||||
|
if got.Grid != "3x3" {
|
||||||
|
t.Fatalf("grid = %q, want 3x3", got.Grid)
|
||||||
|
}
|
||||||
|
if len(warns) != 0 {
|
||||||
|
t.Errorf("unexpected warnings: %v", warns)
|
||||||
|
}
|
||||||
|
for i, tile := range got.Tiles {
|
||||||
|
if tile.Col != i%3 || tile.Row != i/3 {
|
||||||
|
t.Errorf("tile %d at (%d,%d), want (%d,%d)", i, tile.Col, tile.Row, i%3, i/3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A count whose uniform grid is larger than the slot count leaves holes, and
|
||||||
|
// the caller must be told rather than silently shipping a wall with blanks.
|
||||||
|
func TestLayoutFromView_WarnsOnHoles(t *testing.T) {
|
||||||
|
got, warns := LayoutFromView(view("Seven", ids(7)...), namesFor(7))
|
||||||
|
if got.Grid != "3x3" {
|
||||||
|
t.Fatalf("grid = %q, want 3x3", got.Grid)
|
||||||
|
}
|
||||||
|
if !hasWarn(warns, "empty") {
|
||||||
|
t.Errorf("expected an empty-cell warning, got %v", warns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown camera id is reported and its cell left out.
|
||||||
|
func TestLayoutFromView_WarnsOnUnknownCamera(t *testing.T) {
|
||||||
|
got, warns := LayoutFromView(view("Office View", ids(8)...), namesFor(7))
|
||||||
|
if len(got.Tiles) != 7 {
|
||||||
|
t.Errorf("got %d tiles, want 7", len(got.Tiles))
|
||||||
|
}
|
||||||
|
if !hasWarn(warns, "not in config") {
|
||||||
|
t.Errorf("expected an unknown-camera warning, got %v", warns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnfilledCountsUncoveredCells(t *testing.T) {
|
||||||
|
p := uniformPreset(3, 3)
|
||||||
|
if n := unfilled(p, nil); n != 9 {
|
||||||
|
t.Errorf("empty layout: got %d uncovered, want 9", n)
|
||||||
|
}
|
||||||
|
full, _ := LayoutFromView(view("Default", ids(9)...), namesFor(9))
|
||||||
|
if n := unfilled(p, full.Tiles); n != 0 {
|
||||||
|
t.Errorf("full 3x3: got %d uncovered, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasWarn(warns []string, substr string) bool {
|
||||||
|
for _, w := range warns {
|
||||||
|
if strings.Contains(w, substr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
34
tui/.gitignore
vendored
Normal file
34
tui/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
99
tui/README.md
Normal file
99
tui/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# rtsp-streamer configurator (opentui)
|
||||||
|
|
||||||
|
The interactive configurator, built with [opentui](https://opentui.com). It
|
||||||
|
replaces the Bubble Tea TUI that lived in `internal/tui`.
|
||||||
|
|
||||||
|
Launch it the usual way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtsp-streamer tui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this is a separate executable
|
||||||
|
|
||||||
|
opentui is a native core written in Zig with **TypeScript** bindings — there are
|
||||||
|
no Go bindings. So this half of the tool is TypeScript compiled by Bun, and it
|
||||||
|
ships as `rtsp-streamer-tui` next to the Go binary. `rtsp-streamer tui` finds and
|
||||||
|
execs it.
|
||||||
|
|
||||||
|
The trade-off is size: the Go binary is ~13 MB, and this one is ~120 MB because
|
||||||
|
Bun embeds its own runtime plus opentui's native library. Nothing needs to be
|
||||||
|
installed on the target, but it is a large artifact for an SD card. (`make tui-pi`
|
||||||
|
comes out ~108 MB — defining `OPENTUI_LIBC` lets Bun embed only the glibc native
|
||||||
|
package instead of both glibc and musl.)
|
||||||
|
|
||||||
|
If it is missing or misbehaving, the previous Go implementation is still there:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtsp-streamer tui --legacy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Division of labour
|
||||||
|
|
||||||
|
Everything that is not presentation stays in Go. This process holds no
|
||||||
|
credentials and never writes the config file itself — it shells back to
|
||||||
|
`rtsp-streamer` for three things:
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| ------------------------------------------ | -------------------------------------------------------- |
|
||||||
|
| `config export` | read the config, plus limits like `max_tiles` |
|
||||||
|
| `config apply` (JSON on stdin) | merge edits, validate, save atomically, reload the daemon |
|
||||||
|
| `discover --json [--enable-rtsp=high,low]` | Protect discovery, writing nothing |
|
||||||
|
|
||||||
|
Two properties of that split are worth keeping:
|
||||||
|
|
||||||
|
- **The controller password never crosses the bridge.** It is `json:"-"` on the
|
||||||
|
way out, and `config apply` only merges `cameras`, `layouts` and
|
||||||
|
`active_layout`, so it cannot be clobbered on the way back in either.
|
||||||
|
- **`config apply` re-reads the file before merging.** A configurator left open
|
||||||
|
for an hour can no longer overwrite a `views import`, a `layout set`, or a hand
|
||||||
|
edit made in the meantime — it only replaces the keys it owns.
|
||||||
|
|
||||||
|
Discovery is previewed rather than applied: `discover --json` writes nothing, the
|
||||||
|
merge happens here, and nothing reaches disk until you press Save.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
| ------------------ | -------------------------------------------------------------- |
|
||||||
|
| `src/index.ts` | entry point: arg parsing, renderer setup, event loop |
|
||||||
|
| `src/keys.ts` | keyboard routing (bindings carried over from the Go version) |
|
||||||
|
| `src/state.ts` | the store — all state and mutations, ported from `internal/tui` |
|
||||||
|
| `src/ui.ts` | renders the store into an opentui tree; grid mouse handling |
|
||||||
|
| `src/bridge.ts` | subprocess calls into the Go binary |
|
||||||
|
| `src/types.ts` | config schema + geometry helpers mirroring `internal/config` |
|
||||||
|
| `src/theme.ts` | the lipgloss palette, resolved to hex |
|
||||||
|
| `src/fixtures.ts` | sample config used by tests and the preview script |
|
||||||
|
| `scripts/preview.ts` | render every screen to stdout as text |
|
||||||
|
|
||||||
|
## Working on it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-tui # run from source against /tmp/rtsp-streamer.yaml, no compile
|
||||||
|
make test-tui # bun test + tsc --noEmit
|
||||||
|
make tui # compile bin/rtsp-streamer-tui for this machine
|
||||||
|
make tui-pi # cross-compile bin/rtsp-streamer-tui-arm64 for a 64-bit Pi
|
||||||
|
bun run scripts/preview.ts # dump every screen as text, no terminal needed
|
||||||
|
```
|
||||||
|
|
||||||
|
`preview.ts` is the quickest way to see a layout change — it uses opentui's test
|
||||||
|
renderer, so it prints the screens without needing a TTY.
|
||||||
|
|
||||||
|
## Two things to know before changing the grid editor
|
||||||
|
|
||||||
|
Both were bugs during the port, and neither is obvious from the code:
|
||||||
|
|
||||||
|
**Do not overlap cell borders.** Sharing a border between neighbouring cells
|
||||||
|
looks like it should work, but a box draws its own corners, so every shared edge
|
||||||
|
renders as `┌` where a lattice needs `┬`. Each cell is a self-contained box that
|
||||||
|
tiles edge-to-edge.
|
||||||
|
|
||||||
|
**Do not rebuild the tree during a drag.** The renderer *captures* the renderable
|
||||||
|
a drag started on and routes the rest of the gesture to it. `render()` destroys
|
||||||
|
and recreates the whole subtree, so a rebuild mid-drag silently ends the drag
|
||||||
|
after the first resize. Instead, `suppressRender` is set for the duration and the
|
||||||
|
dragged tile's box is resized in place, with one full rebuild on release. For the
|
||||||
|
same reason the grid's mouse handlers live on the persistent mount box, not on
|
||||||
|
the grid box, and the grid's screen origin is captured on press rather than read
|
||||||
|
live (a rebuilt box has no computed layout until the next frame, so `screenX`
|
||||||
|
reads as 0).
|
||||||
65
tui/bun.lock
Normal file
65
tui/bun.lock
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "tui",
|
||||||
|
"dependencies": {
|
||||||
|
"@opentui/core": "^0.4.5",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
||||||
|
|
||||||
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||||
|
|
||||||
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
|
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||||
|
|
||||||
|
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tui/package.json
Normal file
23
tui/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "rtsp-streamer-tui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "opentui configurator for rtsp-streamer",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "bun test",
|
||||||
|
"build": "bun build --compile src/index.ts --outfile ../bin/rtsp-streamer-tui"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@opentui/core": "^0.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tui/scripts/preview.ts
Normal file
42
tui/scripts/preview.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Render each screen to plain text and print it, without a terminal.
|
||||||
|
*
|
||||||
|
* This is a development aid, not a test — it exists so a change to the layout
|
||||||
|
* can be eyeballed in one command (`bun run scripts/preview.ts`) instead of
|
||||||
|
* being driven by hand over SSH. src/ui.test.ts asserts the parts that matter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createTestRenderer } from "@opentui/core/testing"
|
||||||
|
import { Store } from "../src/state.ts"
|
||||||
|
import { UI } from "../src/ui.ts"
|
||||||
|
import { sampleDoc } from "../src/fixtures.ts"
|
||||||
|
import type { Screen } from "../src/state.ts"
|
||||||
|
|
||||||
|
const SCREENS: Screen[] = ["menu", "cameras", "layouts", "layoutEdit", "cameraPicker", "setActive"]
|
||||||
|
|
||||||
|
const setup = await createTestRenderer({ width: 96, height: 30 })
|
||||||
|
try {
|
||||||
|
const store = new Store()
|
||||||
|
store.hydrate(sampleDoc())
|
||||||
|
const ui = new UI(setup.renderer, store)
|
||||||
|
store.subscribe(() => ui.render())
|
||||||
|
|
||||||
|
for (const screen of SCREENS) {
|
||||||
|
if (screen === "layoutEdit" || screen === "cameraPicker") {
|
||||||
|
// Land on a layout with spanning tiles so the interesting case is shown.
|
||||||
|
store.openEditor(0)
|
||||||
|
store.setCell(3, 1)
|
||||||
|
store.screen = screen
|
||||||
|
} else {
|
||||||
|
store.screen = screen
|
||||||
|
}
|
||||||
|
store.cursor = 0
|
||||||
|
ui.render()
|
||||||
|
await setup.flush()
|
||||||
|
console.log(`\n===== ${screen} =====`)
|
||||||
|
console.log(setup.captureCharFrame().replace(/ +$/gm, ""))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setup.renderer.destroy()
|
||||||
|
}
|
||||||
116
tui/src/bridge.ts
Normal file
116
tui/src/bridge.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Calls into the Go binary. Every piece of domain logic lives there; this
|
||||||
|
* module is only transport.
|
||||||
|
*
|
||||||
|
* Each bridge command prints one JSON document on stdout even when it fails,
|
||||||
|
* so we parse stdout regardless of exit status and read `ok` from the payload.
|
||||||
|
* A non-zero exit with unparseable stdout means something went wrong before the
|
||||||
|
* command ran (missing binary, bad flag) — that becomes a thrown Error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApplyResult, Config, DiscoverDoc, ExportDoc } from "./types.ts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the rtsp-streamer binary. `rtsp-streamer tui` sets
|
||||||
|
* RTSP_STREAMER_BIN to its own path so a locally-built binary is not shadowed
|
||||||
|
* by an installed one; standalone runs fall back to $PATH.
|
||||||
|
*/
|
||||||
|
const GO_BIN = process.env.RTSP_STREAMER_BIN || "rtsp-streamer"
|
||||||
|
|
||||||
|
export class BridgeError extends Error {}
|
||||||
|
|
||||||
|
interface RunResult {
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
exitCode: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(args: string[], stdin?: string): Promise<RunResult> {
|
||||||
|
let proc
|
||||||
|
try {
|
||||||
|
proc = Bun.spawn([GO_BIN, ...args], {
|
||||||
|
stdin: stdin === undefined ? "ignore" : new TextEncoder().encode(stdin),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
} catch (cause) {
|
||||||
|
throw new BridgeError(`cannot run ${GO_BIN}: ${cause instanceof Error ? cause.message : String(cause)}`)
|
||||||
|
}
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
])
|
||||||
|
return { stdout, stderr, exitCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a bridge command and parse its JSON. Throws BridgeError when there is no
|
||||||
|
* JSON to read, using stderr for the message since that is where cobra reports
|
||||||
|
* usage and startup failures.
|
||||||
|
*/
|
||||||
|
async function runJSON<T>(args: string[], stdin?: string): Promise<T> {
|
||||||
|
const { stdout, stderr, exitCode } = await run(args, stdin)
|
||||||
|
const text = stdout.trim()
|
||||||
|
if (!text) {
|
||||||
|
const detail = cleanStderr(stderr) || `exit status ${exitCode}`
|
||||||
|
throw new BridgeError(detail)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
} catch {
|
||||||
|
throw new BridgeError(cleanStderr(stderr) || `unexpected output from \`${args.join(" ")}\`: ${text.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tidy a Go-side error for re-display. The CLI prefixes its failures with
|
||||||
|
* "error: "; callers here add their own prefix, so keeping Go's would render as
|
||||||
|
* "error: error: invalid config ...".
|
||||||
|
*/
|
||||||
|
function cleanStderr(stderr: string): string {
|
||||||
|
return stderr.trim().replace(/^error:\s*/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The config path to operate on, threaded through as an explicit --config. */
|
||||||
|
export interface BridgeOptions {
|
||||||
|
configPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function withConfig(args: string[], opts: BridgeOptions): string[] {
|
||||||
|
return opts.configPath ? ["--config", opts.configPath, ...args] : args
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the config and the constants the editor needs. */
|
||||||
|
export function exportConfig(opts: BridgeOptions = {}): Promise<ExportDoc> {
|
||||||
|
return runJSON<ExportDoc>(withConfig(["config", "export"], opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the edited cameras/layouts/active layout and reload a running daemon.
|
||||||
|
*
|
||||||
|
* Only these three keys are sent: Go merges them onto a fresh read of the file,
|
||||||
|
* so the controller section (including its password) and anything changed on
|
||||||
|
* disk while the TUI was open are left alone.
|
||||||
|
*/
|
||||||
|
export function applyConfig(cfg: Config, opts: BridgeOptions = {}): Promise<ApplyResult> {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
cameras: cfg.cameras,
|
||||||
|
layouts: cfg.layouts,
|
||||||
|
active_layout: cfg.active_layout,
|
||||||
|
})
|
||||||
|
return runJSON<ApplyResult>(withConfig(["config", "apply"], opts), payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover cameras from UniFi Protect without writing anything.
|
||||||
|
*
|
||||||
|
* `enable` is a comma-separated quality list ("high,low") to switch RTSP on for
|
||||||
|
* in Protect as a side effect of discovery; empty just reports what is already
|
||||||
|
* enabled.
|
||||||
|
*/
|
||||||
|
export function discover(enable: string, opts: BridgeOptions = {}): Promise<DiscoverDoc> {
|
||||||
|
const args = ["discover", "--json"]
|
||||||
|
if (enable) args.push(`--enable-rtsp=${enable}`)
|
||||||
|
return runJSON<DiscoverDoc>(withConfig(args, opts))
|
||||||
|
}
|
||||||
50
tui/src/fixtures.ts
Normal file
50
tui/src/fixtures.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* A sample export document, shared by the tests and the preview script.
|
||||||
|
*
|
||||||
|
* Shaped to cover the cases that have actually caused trouble: a spanning tile,
|
||||||
|
* an empty cell, a tile pinned to a quality versus one left on auto, a disabled
|
||||||
|
* camera (which must not appear in the picker), a camera with only a substream,
|
||||||
|
* and a legacy slots-only layout that has to be upgraded on open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExportDoc } from "./types.ts"
|
||||||
|
|
||||||
|
export function sampleDoc(): ExportDoc {
|
||||||
|
return {
|
||||||
|
config_path: "/tmp/rtsp-streamer-preview.yaml",
|
||||||
|
max_tiles: 16,
|
||||||
|
max_grid_dim: 8,
|
||||||
|
qualities: ["high", "medium", "low"],
|
||||||
|
daemon_running: true,
|
||||||
|
config: {
|
||||||
|
controller: { host: "192.168.1.1", username: "viewer", rtsp_port: 7441 },
|
||||||
|
display: {},
|
||||||
|
player: {},
|
||||||
|
cameras: [
|
||||||
|
{ id: "c1", name: "Driveway", streams: { high: "rtsps://x/dw-hi", low: "rtsps://x/dw-lo" } },
|
||||||
|
{ id: "c2", name: "Front Door", streams: { high: "rtsps://x/fd-hi", low: "rtsps://x/fd-lo" } },
|
||||||
|
{ id: "c3", name: "Back Yard", streams: { low: "rtsps://x/by-lo" } },
|
||||||
|
{ id: "c4", name: "Side Gate", streams: { high: "rtsps://x/sg-hi" }, disabled: true },
|
||||||
|
{ name: "Shed (manual)", rtsp: "rtsp://10.0.0.9:554/stream1" },
|
||||||
|
],
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
name: "main-plus",
|
||||||
|
grid: "4x3",
|
||||||
|
tiles: [
|
||||||
|
// A 3x2 hero tile with a column of small tiles down the right.
|
||||||
|
{ camera: "Driveway", col: 0, row: 0, colspan: 3, rowspan: 2, quality: "high" },
|
||||||
|
{ camera: "Front Door", col: 3, row: 0, colspan: 1, rowspan: 1, quality: "low" },
|
||||||
|
{ camera: "Back Yard", col: 3, row: 2, colspan: 1, rowspan: 1 },
|
||||||
|
// (3,1) and the bottom-left cells stay empty on purpose.
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "quad", grid: "2x2", tiles: [{ camera: "Driveway", col: 0, row: 0 }] },
|
||||||
|
// Legacy row-major form; openEditor() upgrades it to tiles.
|
||||||
|
{ name: "legacy", grid: "2x1", slots: ["Front Door", "Back Yard"] },
|
||||||
|
],
|
||||||
|
active_layout: "main-plus",
|
||||||
|
clock: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tui/src/index.ts
Normal file
99
tui/src/index.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* rtsp-streamer configurator — an opentui port of the Bubble Tea TUI that used
|
||||||
|
* to live in internal/tui.
|
||||||
|
*
|
||||||
|
* Meant to be driven over SSH on the headless Pi: browse cameras, place them
|
||||||
|
* into layout slots, pick the active layout, discover cameras from UniFi
|
||||||
|
* Protect, and save — which signals the running daemon to reload.
|
||||||
|
*
|
||||||
|
* All config and Protect work is delegated to the Go binary (see bridge.ts), so
|
||||||
|
* this process holds no credentials and never writes the config file itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createCliRenderer, type KeyEvent } from "@opentui/core"
|
||||||
|
import { BridgeError } from "./bridge.ts"
|
||||||
|
import { handleKey } from "./keys.ts"
|
||||||
|
import { Store } from "./state.ts"
|
||||||
|
import { UI } from "./ui.ts"
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): { configPath?: string; help: boolean } {
|
||||||
|
let configPath: string | undefined
|
||||||
|
let help = false
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i]
|
||||||
|
if (a === "--config" || a === "-c") configPath = argv[++i]
|
||||||
|
else if (a?.startsWith("--config=")) configPath = a.slice("--config=".length)
|
||||||
|
else if (a === "--help" || a === "-h") help = true
|
||||||
|
}
|
||||||
|
return { configPath, help }
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE = `rtsp-streamer configurator (opentui)
|
||||||
|
|
||||||
|
Usage: rtsp-streamer-tui [--config PATH]
|
||||||
|
|
||||||
|
Normally launched as \`rtsp-streamer tui\`, which passes --config and points
|
||||||
|
RTSP_STREAMER_BIN at itself.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
RTSP_STREAMER_BIN path to the rtsp-streamer binary (default: from $PATH)
|
||||||
|
RTSP_STREAMER_CONFIG default config path when --config is not given
|
||||||
|
`
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const { configPath, help } = parseArgs(process.argv.slice(2))
|
||||||
|
if (help) {
|
||||||
|
process.stdout.write(USAGE)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load before taking over the terminal: a bad config or a missing Go binary
|
||||||
|
// should print a plain error, not a broken frame in the alternate screen.
|
||||||
|
const store = new Store()
|
||||||
|
try {
|
||||||
|
await store.load(configPath)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof BridgeError || e instanceof Error ? e.message : String(e)
|
||||||
|
process.stderr.write(`error: ${msg}\n`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderer = await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 })
|
||||||
|
const ui = new UI(renderer, store)
|
||||||
|
const unsubscribe = store.subscribe(() => ui.render())
|
||||||
|
ui.render()
|
||||||
|
|
||||||
|
let done = false
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
unsubscribe()
|
||||||
|
renderer.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
||||||
|
if (done) return
|
||||||
|
// Ctrl+C always exits, even mid-edit.
|
||||||
|
if (key.ctrl && key.name === "c") {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
handleKey(store, ui, key)
|
||||||
|
} catch (e) {
|
||||||
|
store.setStatus(`error: ${e instanceof Error ? e.message : String(e)}`, true)
|
||||||
|
}
|
||||||
|
if (store.quitRequested) finish()
|
||||||
|
})
|
||||||
|
|
||||||
|
renderer.on("resize", () => ui.render())
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
renderer.on("destroy", () => resolve())
|
||||||
|
})
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = await main()
|
||||||
105
tui/src/keys.ts
Normal file
105
tui/src/keys.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Keyboard routing.
|
||||||
|
*
|
||||||
|
* The bindings are carried over verbatim from the Bubble Tea version — vim
|
||||||
|
* motions alongside the arrows, shifted HJKL to resize a tile, `q` meaning
|
||||||
|
* "quality" inside the editor but "quit" at the menu — so existing muscle
|
||||||
|
* memory keeps working. `S` saves from anywhere, which is new.
|
||||||
|
*
|
||||||
|
* Matching is on `key.sequence` for printable keys because it preserves case
|
||||||
|
* (`key.name` reports "l" for both `l` and `L`, which would collapse "move
|
||||||
|
* right" into "make wider"), and on `key.name` for the named keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { KeyEvent } from "@opentui/core"
|
||||||
|
import type { Store } from "./state.ts"
|
||||||
|
|
||||||
|
/** What the key handler needs from the UI: menu activation. */
|
||||||
|
export interface MenuActivator {
|
||||||
|
activateMenu(idx: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleKey(store: Store, ui: MenuActivator, key: KeyEvent): void {
|
||||||
|
// Ignore input while a discover or save is in flight, rather than queueing
|
||||||
|
// keys that would act on state the bridge call is about to replace.
|
||||||
|
if (store.busy) return
|
||||||
|
|
||||||
|
const name = key.name
|
||||||
|
const seq = key.sequence
|
||||||
|
const back = name === "escape" || name === "backspace"
|
||||||
|
|
||||||
|
switch (store.screen) {
|
||||||
|
case "menu": {
|
||||||
|
const n = store.listLength()
|
||||||
|
if (name === "up" || seq === "k") store.moveCursor(-1, n)
|
||||||
|
else if (name === "down" || seq === "j") store.moveCursor(1, n)
|
||||||
|
else if (name === "return" || name === "space" || seq === "l") ui.activateMenu(store.cursor)
|
||||||
|
else if (seq === "q") store.requestQuit()
|
||||||
|
else if (seq === "S") void store.save()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "cameras": {
|
||||||
|
const n = store.listLength()
|
||||||
|
if (back || seq === "h") store.go("menu")
|
||||||
|
else if (name === "up" || seq === "k") store.moveCursor(-1, n)
|
||||||
|
else if (name === "down" || seq === "j") store.moveCursor(1, n)
|
||||||
|
else if (seq === "d") store.toggleCameraDisabled()
|
||||||
|
else if (seq === "x") store.deleteCamera()
|
||||||
|
else if (seq === "S") void store.save()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "layouts": {
|
||||||
|
const n = store.listLength()
|
||||||
|
if (back || seq === "h") store.go("menu")
|
||||||
|
else if (name === "up" || seq === "k") store.moveCursor(-1, n)
|
||||||
|
else if (name === "down" || seq === "j") store.moveCursor(1, n)
|
||||||
|
else if (name === "return" || seq === "l") store.openEditor(store.cursor)
|
||||||
|
else if (seq === "S") void store.save()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "setActive": {
|
||||||
|
const n = store.listLength()
|
||||||
|
if (back || seq === "h") store.go("menu")
|
||||||
|
else if (name === "up" || seq === "k") store.moveCursor(-1, n)
|
||||||
|
else if (name === "down" || seq === "j") store.moveCursor(1, n)
|
||||||
|
else if (name === "return" || seq === "l") store.setActiveLayout(store.cursor)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "cameraPicker": {
|
||||||
|
const n = store.listLength()
|
||||||
|
if (back || seq === "h") store.go("layoutEdit")
|
||||||
|
else if (name === "up" || seq === "k") store.moveCursor(-1, n)
|
||||||
|
else if (name === "down" || seq === "j") store.moveCursor(1, n)
|
||||||
|
else if (name === "return" || seq === "l") store.choosePickerOption(store.cursor)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "layoutEdit": {
|
||||||
|
// Returning to the layout list puts the cursor back on the layout that
|
||||||
|
// was being edited, not at the top.
|
||||||
|
if (back) store.go("layouts", store.editLayout)
|
||||||
|
else if (name === "up" || seq === "k") store.moveCell(0, -1)
|
||||||
|
else if (name === "down" || seq === "j") store.moveCell(0, 1)
|
||||||
|
else if (name === "left" || seq === "h") store.moveCell(-1, 0)
|
||||||
|
else if (name === "right" || seq === "l") store.moveCell(1, 0)
|
||||||
|
else if (name === "return" || name === "space" || seq === "a") store.go("cameraPicker")
|
||||||
|
else if (seq === "c") store.clearAt()
|
||||||
|
else if (seq === "q") store.cycleQuality()
|
||||||
|
// Shifted motions resize the tile; unshifted move the cursor.
|
||||||
|
else if (seq === "L") store.resizeTile(1, 0)
|
||||||
|
else if (seq === "H") store.resizeTile(-1, 0)
|
||||||
|
else if (seq === "J") store.resizeTile(0, 1)
|
||||||
|
else if (seq === "K") store.resizeTile(0, -1)
|
||||||
|
else if (seq === "]") store.resizeGrid(1, 0)
|
||||||
|
else if (seq === "[") store.resizeGrid(-1, 0)
|
||||||
|
else if (seq === "}") store.resizeGrid(0, 1)
|
||||||
|
else if (seq === "{") store.resizeGrid(0, -1)
|
||||||
|
else if (seq === "S") void store.save()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
309
tui/src/state.test.ts
Normal file
309
tui/src/state.test.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import { sampleDoc } from "./fixtures.ts"
|
||||||
|
import { handleKey } from "./keys.ts"
|
||||||
|
import { PICKER_EMPTY, Store } from "./state.ts"
|
||||||
|
import type { KeyEvent } from "@opentui/core"
|
||||||
|
|
||||||
|
/** Minimal KeyEvent stand-in; handleKey only reads name/sequence. */
|
||||||
|
function key(seq: string, name = seq): KeyEvent {
|
||||||
|
return { name, sequence: seq } as KeyEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collects menu activations so key tests don't need the real UI. */
|
||||||
|
const noopUI = { activateMenu: () => {} }
|
||||||
|
|
||||||
|
let store: Store
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new Store()
|
||||||
|
store.hydrate(sampleDoc())
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cursor movement", () => {
|
||||||
|
test("wraps around both ends", () => {
|
||||||
|
store.screen = "menu"
|
||||||
|
store.cursor = 0
|
||||||
|
store.moveCursor(-1, 7)
|
||||||
|
expect(store.cursor).toBe(6)
|
||||||
|
store.moveCursor(1, 7)
|
||||||
|
expect(store.cursor).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stays at 0 for an empty list", () => {
|
||||||
|
store.moveCursor(1, 0)
|
||||||
|
expect(store.cursor).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("openEditor", () => {
|
||||||
|
test("upgrades a legacy slots layout to tiles", () => {
|
||||||
|
// Index 2 of the fixture is the slots-only "legacy" layout.
|
||||||
|
store.openEditor(2)
|
||||||
|
const l = store.layout!
|
||||||
|
expect(l.slots).toBeUndefined()
|
||||||
|
expect(l.tiles).toEqual([
|
||||||
|
{ camera: "Front Door", col: 0, row: 0, colspan: 1, rowspan: 1 },
|
||||||
|
{ camera: "Back Yard", col: 1, row: 0, colspan: 1, rowspan: 1 },
|
||||||
|
])
|
||||||
|
expect(store.screen).toBe("layoutEdit")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resets the cell cursor", () => {
|
||||||
|
store.edCol = 3
|
||||||
|
store.edRow = 2
|
||||||
|
store.openEditor(1)
|
||||||
|
expect([store.edCol, store.edRow]).toEqual([0, 0])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("grid cursor", () => {
|
||||||
|
beforeEach(() => store.openEditor(0)) // main-plus, 4x3
|
||||||
|
|
||||||
|
test("clamps at the grid edges", () => {
|
||||||
|
store.moveCell(-1, -1)
|
||||||
|
expect([store.edCol, store.edRow]).toEqual([0, 0])
|
||||||
|
store.setCell(3, 2)
|
||||||
|
store.moveCell(1, 1)
|
||||||
|
expect([store.edCol, store.edRow]).toEqual([3, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("finds a spanning tile from any of its cells", () => {
|
||||||
|
store.setCell(2, 1) // inside the 3x2 Driveway tile
|
||||||
|
expect(store.tileIndexAtCursor()).toBe(0)
|
||||||
|
store.setCell(3, 1) // the empty cell
|
||||||
|
expect(store.tileIndexAtCursor()).toBe(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("assignCamera", () => {
|
||||||
|
beforeEach(() => store.openEditor(1)) // quad, 2x2, one tile at (0,0)
|
||||||
|
|
||||||
|
test("creates a 1x1 tile on an empty cell", () => {
|
||||||
|
store.setCell(1, 1)
|
||||||
|
store.assignCamera("Front Door")
|
||||||
|
expect(store.layout!.tiles).toContainEqual({
|
||||||
|
camera: "Front Door",
|
||||||
|
col: 1,
|
||||||
|
row: 1,
|
||||||
|
colspan: 1,
|
||||||
|
rowspan: 1,
|
||||||
|
})
|
||||||
|
expect(store.dirty).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retargets an existing tile without changing its span", () => {
|
||||||
|
store.setCell(0, 0)
|
||||||
|
store.layout!.tiles![0]!.colspan = 2
|
||||||
|
store.assignCamera("Back Yard")
|
||||||
|
expect(store.layout!.tiles![0]).toMatchObject({ camera: "Back Yard", colspan: 2 })
|
||||||
|
expect(store.layout!.tiles!.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an empty choice removes the tile", () => {
|
||||||
|
store.setCell(0, 0)
|
||||||
|
store.assignCamera("")
|
||||||
|
expect(store.layout!.tiles).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refuses to exceed maxTiles", () => {
|
||||||
|
store.maxTiles = 1 // the layout already has one tile
|
||||||
|
store.setCell(1, 1)
|
||||||
|
store.assignCamera("Front Door")
|
||||||
|
expect(store.layout!.tiles!.length).toBe(1)
|
||||||
|
expect(store.isError).toBe(true)
|
||||||
|
expect(store.status).toContain("layout is full")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("camera picker", () => {
|
||||||
|
test("omits disabled cameras and leads with the empty sentinel", () => {
|
||||||
|
// "Side Gate" is disabled in the fixture.
|
||||||
|
expect(store.pickerOptions()).toEqual([
|
||||||
|
PICKER_EMPTY,
|
||||||
|
"Driveway",
|
||||||
|
"Front Door",
|
||||||
|
"Back Yard",
|
||||||
|
"Shed (manual)",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("index 0 clears the cell rather than assigning a camera named '(empty)'", () => {
|
||||||
|
store.openEditor(1)
|
||||||
|
store.setCell(0, 0)
|
||||||
|
store.choosePickerOption(0)
|
||||||
|
expect(store.layout!.tiles).toEqual([])
|
||||||
|
expect(store.screen).toBe("layoutEdit")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports cameras already placed in this layout", () => {
|
||||||
|
store.openEditor(0)
|
||||||
|
expect(store.usedCameras()).toEqual(new Set(["Driveway", "Front Door", "Back Yard"]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resizeTile", () => {
|
||||||
|
beforeEach(() => store.openEditor(0)) // main-plus 4x3
|
||||||
|
|
||||||
|
test("grows into free space", () => {
|
||||||
|
// Front Door is 1x1 at (3,0) and (3,1) is empty, so it can grow downward.
|
||||||
|
store.setCell(3, 0)
|
||||||
|
store.resizeTile(0, 1)
|
||||||
|
expect(store.layout!.tiles![1]).toMatchObject({ camera: "Front Door", rowspan: 2 })
|
||||||
|
expect(store.dirty).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refuses to grow past the last row", () => {
|
||||||
|
store.setCell(3, 2) // Back Yard, on the bottom row of a 3-row grid
|
||||||
|
store.resizeTile(0, 1)
|
||||||
|
expect(store.status).toContain("can't resize")
|
||||||
|
expect(store.layout!.tiles![2]!.rowspan ?? 1).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refuses to overlap a neighbour", () => {
|
||||||
|
store.setCell(0, 0) // the 3x2 hero tile
|
||||||
|
store.resizeTile(1, 0) // would run into Front Door at (3,0)
|
||||||
|
expect(store.status).toContain("can't resize")
|
||||||
|
expect(store.layout!.tiles![0]!.colspan).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shrinks and never goes below 1", () => {
|
||||||
|
store.setCell(0, 0)
|
||||||
|
store.resizeTile(-1, 0)
|
||||||
|
expect(store.layout!.tiles![0]!.colspan).toBe(2)
|
||||||
|
store.resizeTile(-1, 0)
|
||||||
|
store.resizeTile(-1, 0) // already at 1
|
||||||
|
expect(store.layout!.tiles![0]!.colspan).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("complains when there is no tile under the cursor", () => {
|
||||||
|
store.setCell(3, 1) // empty
|
||||||
|
store.resizeTile(1, 0)
|
||||||
|
expect(store.isError).toBe(true)
|
||||||
|
expect(store.status).toContain("assign a camera first")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resizeGrid", () => {
|
||||||
|
beforeEach(() => store.openEditor(0)) // 4x3
|
||||||
|
|
||||||
|
test("grows and shrinks the base grid", () => {
|
||||||
|
store.resizeGrid(1, 0)
|
||||||
|
expect(store.layout!.grid).toBe("5x3")
|
||||||
|
store.resizeGrid(-1, 0)
|
||||||
|
expect(store.layout!.grid).toBe("4x3")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refuses a shrink that would orphan a tile", () => {
|
||||||
|
store.resizeGrid(-1, 0) // Front Door sits at col 3
|
||||||
|
expect(store.layout!.grid).toBe("4x3")
|
||||||
|
expect(store.status).toContain("shrink blocked")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("respects maxGridDim", () => {
|
||||||
|
store.maxGridDim = 4
|
||||||
|
store.resizeGrid(1, 0)
|
||||||
|
expect(store.layout!.grid).toBe("4x3")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("pulls the cell cursor back inside after a shrink", () => {
|
||||||
|
store.layout!.tiles = [] // nothing to block the shrink
|
||||||
|
store.setCell(3, 2)
|
||||||
|
store.resizeGrid(-1, -1)
|
||||||
|
expect(store.layout!.grid).toBe("3x2")
|
||||||
|
expect([store.edCol, store.edRow]).toEqual([2, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cycleQuality", () => {
|
||||||
|
beforeEach(() => store.openEditor(0))
|
||||||
|
|
||||||
|
test("cycles auto → the camera's available qualities → auto", () => {
|
||||||
|
store.setCell(3, 2) // Back Yard: only a low stream, currently auto
|
||||||
|
expect(store.layout!.tiles![2]!.quality).toBeUndefined()
|
||||||
|
store.cycleQuality()
|
||||||
|
expect(store.layout!.tiles![2]!.quality).toBe("low")
|
||||||
|
store.cycleQuality()
|
||||||
|
expect(store.layout!.tiles![2]!.quality).toBeUndefined()
|
||||||
|
expect(store.status).toContain("auto")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("only offers qualities the camera actually has", () => {
|
||||||
|
store.setCell(0, 0) // Driveway: high + low, starts pinned to high
|
||||||
|
store.cycleQuality()
|
||||||
|
expect(store.layout!.tiles![0]!.quality).toBe("low")
|
||||||
|
store.cycleQuality()
|
||||||
|
expect(store.layout!.tiles![0]!.quality).toBeUndefined() // never "medium"
|
||||||
|
})
|
||||||
|
|
||||||
|
test("complains on an empty cell", () => {
|
||||||
|
store.setCell(3, 1)
|
||||||
|
store.cycleQuality()
|
||||||
|
expect(store.isError).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cameras screen", () => {
|
||||||
|
test("toggles disabled", () => {
|
||||||
|
store.screen = "cameras"
|
||||||
|
store.cursor = 0
|
||||||
|
store.toggleCameraDisabled()
|
||||||
|
expect(store.cfg.cameras[0]!.disabled).toBe(true)
|
||||||
|
store.toggleCameraDisabled()
|
||||||
|
expect(store.cfg.cameras[0]!.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deleting the last row moves the cursor back", () => {
|
||||||
|
store.screen = "cameras"
|
||||||
|
store.cursor = 4 // the final camera
|
||||||
|
store.deleteCamera()
|
||||||
|
expect(store.cfg.cameras.length).toBe(4)
|
||||||
|
expect(store.cursor).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("setActiveLayout", () => {
|
||||||
|
test("sets the name and returns to the menu", () => {
|
||||||
|
store.setActiveLayout(1)
|
||||||
|
expect(store.cfg.active_layout).toBe("quad")
|
||||||
|
expect(store.screen).toBe("menu")
|
||||||
|
expect(store.dirty).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("key routing", () => {
|
||||||
|
test("q quits at the menu but cycles quality in the editor", () => {
|
||||||
|
store.screen = "menu"
|
||||||
|
handleKey(store, noopUI, key("q"))
|
||||||
|
expect(store.quitRequested).toBe(true)
|
||||||
|
|
||||||
|
const s2 = new Store()
|
||||||
|
s2.hydrate(sampleDoc())
|
||||||
|
s2.openEditor(0)
|
||||||
|
s2.setCell(3, 2)
|
||||||
|
handleKey(s2, noopUI, key("q"))
|
||||||
|
expect(s2.quitRequested).toBe(false)
|
||||||
|
expect(s2.layout!.tiles![2]!.quality).toBe("low")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("l moves the cell cursor while L widens the tile", () => {
|
||||||
|
store.openEditor(1) // quad, tile at (0,0)
|
||||||
|
handleKey(store, noopUI, key("l"))
|
||||||
|
expect(store.edCol).toBe(1)
|
||||||
|
store.setCell(0, 0)
|
||||||
|
handleKey(store, noopUI, key("L"))
|
||||||
|
expect(store.layout!.tiles![0]!.colspan).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("escape from the editor returns to the layout it was editing", () => {
|
||||||
|
store.openEditor(2)
|
||||||
|
handleKey(store, noopUI, key("\x1b", "escape"))
|
||||||
|
expect(store.screen).toBe("layouts")
|
||||||
|
expect(store.cursor).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("input is ignored while a bridge call is in flight", () => {
|
||||||
|
store.screen = "cameras"
|
||||||
|
store.busy = true
|
||||||
|
handleKey(store, noopUI, key("x"))
|
||||||
|
expect(store.cfg.cameras.length).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
476
tui/src/state.ts
Normal file
476
tui/src/state.ts
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* Application state and every mutation on it — the port of the Bubble Tea
|
||||||
|
* model from internal/tui/tui.go and internal/tui/gridedit.go.
|
||||||
|
*
|
||||||
|
* This is deliberately a plain observable object rather than anything reactive.
|
||||||
|
* The original was an Elm-style model where each keypress produced a new state
|
||||||
|
* and the whole view was re-derived; keeping that shape makes the port readable
|
||||||
|
* next to the Go it came from, and the UI layer just rebuilds on notify().
|
||||||
|
*
|
||||||
|
* Nothing here touches the filesystem or Protect: saving and discovery go
|
||||||
|
* through the bridge, so validation and atomic writes stay in Go.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { applyConfig, BridgeError, discover, exportConfig } from "./bridge.ts"
|
||||||
|
import {
|
||||||
|
availableQualities,
|
||||||
|
cameraByName,
|
||||||
|
effectiveTiles,
|
||||||
|
layoutDimensions,
|
||||||
|
regionFree,
|
||||||
|
tileIndexAt,
|
||||||
|
tileSpan,
|
||||||
|
type Config,
|
||||||
|
type ExportDoc,
|
||||||
|
type Layout,
|
||||||
|
type Quality,
|
||||||
|
type Tile,
|
||||||
|
} from "./types.ts"
|
||||||
|
|
||||||
|
export type Screen = "menu" | "cameras" | "layouts" | "layoutEdit" | "cameraPicker" | "setActive"
|
||||||
|
|
||||||
|
export const MENU_ITEMS = [
|
||||||
|
"Cameras",
|
||||||
|
"Layouts",
|
||||||
|
"Set active layout",
|
||||||
|
"Discover from UniFi Protect",
|
||||||
|
"Discover + enable RTSP (hi+lo)",
|
||||||
|
"Save",
|
||||||
|
"Quit",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const enum MenuItem {
|
||||||
|
Cameras = 0,
|
||||||
|
Layouts = 1,
|
||||||
|
SetActive = 2,
|
||||||
|
Discover = 3,
|
||||||
|
DiscoverEnable = 4,
|
||||||
|
Save = 5,
|
||||||
|
Quit = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "(empty)" sentinel at the top of the camera picker. */
|
||||||
|
export const PICKER_EMPTY = "(empty)"
|
||||||
|
|
||||||
|
export class Store {
|
||||||
|
configPath = ""
|
||||||
|
maxTiles = 16
|
||||||
|
maxGridDim = 8
|
||||||
|
qualities: Quality[] = ["high", "medium", "low"]
|
||||||
|
daemonRunning = false
|
||||||
|
|
||||||
|
cfg: Config = {
|
||||||
|
controller: {},
|
||||||
|
display: {},
|
||||||
|
player: {},
|
||||||
|
cameras: [],
|
||||||
|
layouts: [],
|
||||||
|
active_layout: "",
|
||||||
|
clock: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty = false
|
||||||
|
screen: Screen = "menu"
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
/** Index into cfg.layouts for the editor and picker screens. */
|
||||||
|
editLayout = 0
|
||||||
|
/** Grid-editor cursor position. */
|
||||||
|
edCol = 0
|
||||||
|
edRow = 0
|
||||||
|
|
||||||
|
status = ""
|
||||||
|
isError = false
|
||||||
|
/** Set while a bridge call is in flight, so keys don't queue up behind it. */
|
||||||
|
busy = false
|
||||||
|
|
||||||
|
/** Set when the user asks to quit; the entry point watches for it. */
|
||||||
|
quitRequested = false
|
||||||
|
|
||||||
|
private listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
subscribe(fn: () => void): () => void {
|
||||||
|
this.listeners.add(fn)
|
||||||
|
return () => this.listeners.delete(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const fn of this.listeners) fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: string, isError = false): void {
|
||||||
|
this.status = status
|
||||||
|
this.isError = isError
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- loading ----
|
||||||
|
|
||||||
|
async load(configPath?: string): Promise<void> {
|
||||||
|
this.hydrate(await exportConfig({ configPath }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed state from an export document. Split out so tests can skip the bridge. */
|
||||||
|
hydrate(doc: ExportDoc): void {
|
||||||
|
this.configPath = doc.config_path
|
||||||
|
this.maxTiles = doc.max_tiles
|
||||||
|
this.maxGridDim = doc.max_grid_dim
|
||||||
|
this.qualities = doc.qualities
|
||||||
|
this.daemonRunning = doc.daemon_running
|
||||||
|
this.cfg = doc.config
|
||||||
|
// A config file that has never been written comes back with null lists.
|
||||||
|
this.cfg.cameras ??= []
|
||||||
|
this.cfg.layouts ??= []
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- navigation ----
|
||||||
|
|
||||||
|
/** Wrap-around cursor movement, as the Go moveCursor. */
|
||||||
|
moveCursor(delta: number, n: number): void {
|
||||||
|
this.cursor = n === 0 ? 0 : (this.cursor + delta + n) % n
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
go(screen: Screen, cursor = 0): void {
|
||||||
|
this.screen = screen
|
||||||
|
this.cursor = cursor
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows on the current list screen — used for cursor bounds and mouse hits. */
|
||||||
|
listLength(): number {
|
||||||
|
switch (this.screen) {
|
||||||
|
case "menu":
|
||||||
|
return MENU_ITEMS.length
|
||||||
|
case "cameras":
|
||||||
|
return this.cfg.cameras.length
|
||||||
|
case "layouts":
|
||||||
|
case "setActive":
|
||||||
|
return this.cfg.layouts.length
|
||||||
|
case "cameraPicker":
|
||||||
|
return this.pickerOptions().length
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cameras ----
|
||||||
|
|
||||||
|
toggleCameraDisabled(): void {
|
||||||
|
const cam = this.cfg.cameras[this.cursor]
|
||||||
|
if (!cam) return
|
||||||
|
cam.disabled = !cam.disabled
|
||||||
|
this.dirty = true
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCamera(): void {
|
||||||
|
if (!this.cfg.cameras[this.cursor]) return
|
||||||
|
this.cfg.cameras.splice(this.cursor, 1)
|
||||||
|
if (this.cursor >= this.cfg.cameras.length && this.cursor > 0) this.cursor--
|
||||||
|
this.dirty = true
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- layouts ----
|
||||||
|
|
||||||
|
get layout(): Layout | undefined {
|
||||||
|
return this.cfg.layouts[this.editLayout]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the grid editor on a layout, upgrading it to the tile model first so
|
||||||
|
* the editor never has to deal with legacy slot layouts.
|
||||||
|
*/
|
||||||
|
openEditor(idx: number): void {
|
||||||
|
const l = this.cfg.layouts[idx]
|
||||||
|
if (!l) return
|
||||||
|
l.tiles = effectiveTiles(l)
|
||||||
|
delete l.slots
|
||||||
|
this.editLayout = idx
|
||||||
|
this.edCol = 0
|
||||||
|
this.edRow = 0
|
||||||
|
this.go("layoutEdit")
|
||||||
|
}
|
||||||
|
|
||||||
|
gridDims(): [cols: number, rows: number] {
|
||||||
|
const l = this.layout
|
||||||
|
return l ? layoutDimensions(l) : [1, 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
private tiles(): Tile[] {
|
||||||
|
const l = this.layout
|
||||||
|
if (!l) return []
|
||||||
|
l.tiles ??= []
|
||||||
|
return l.tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera names already placed in the layout being edited. */
|
||||||
|
usedCameras(): Set<string> {
|
||||||
|
return new Set(this.tiles().filter((t) => t.camera).map((t) => t.camera))
|
||||||
|
}
|
||||||
|
|
||||||
|
tileIndexAtCursor(): number {
|
||||||
|
return tileIndexAt(this.tiles(), this.edCol, this.edRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
moveCell(dCol: number, dRow: number): void {
|
||||||
|
const [cols, rows] = this.gridDims()
|
||||||
|
this.edCol = Math.min(Math.max(this.edCol + dCol, 0), cols - 1)
|
||||||
|
this.edRow = Math.min(Math.max(this.edRow + dRow, 0), rows - 1)
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
setCell(col: number, row: number): void {
|
||||||
|
this.edCol = col
|
||||||
|
this.edRow = row
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the tile under the cursor, if any. */
|
||||||
|
clearAt(): void {
|
||||||
|
const idx = this.tileIndexAtCursor()
|
||||||
|
if (idx < 0) return
|
||||||
|
this.tiles().splice(idx, 1)
|
||||||
|
this.dirty = true
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step the tile under the cursor through auto → its camera's available
|
||||||
|
* qualities. Small tiles want "low" and big ones "high" on a Pi, but that is
|
||||||
|
* the operator's call; this just cycles.
|
||||||
|
*/
|
||||||
|
cycleQuality(): void {
|
||||||
|
const idx = this.tileIndexAtCursor()
|
||||||
|
if (idx < 0) {
|
||||||
|
this.setStatus("no tile here — assign a camera first", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t = this.tiles()[idx]!
|
||||||
|
// "" is auto: the camera's best available stream.
|
||||||
|
const opts: Quality[] = ["", ...availableQualities(cameraByName(this.cfg, t.camera), this.qualities)]
|
||||||
|
const cur = Math.max(0, opts.indexOf(t.quality ?? ""))
|
||||||
|
const next = opts[(cur + 1) % opts.length]!
|
||||||
|
if (next === "") delete t.quality
|
||||||
|
else t.quality = next
|
||||||
|
this.dirty = true
|
||||||
|
this.setStatus(`tile quality: ${next === "" ? "auto" : next}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grow or shrink the tile under the cursor, in-bounds and non-overlapping. */
|
||||||
|
resizeTile(dCol: number, dRow: number): void {
|
||||||
|
const idx = this.tileIndexAtCursor()
|
||||||
|
if (idx < 0) {
|
||||||
|
this.setStatus("no tile here — assign a camera first", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tiles = this.tiles()
|
||||||
|
const t = tiles[idx]!
|
||||||
|
const [cs, rs] = tileSpan(t)
|
||||||
|
const newCS = cs + dCol
|
||||||
|
const newRS = rs + dRow
|
||||||
|
if (newCS < 1 || newRS < 1) return
|
||||||
|
const [cols, rows] = this.gridDims()
|
||||||
|
if (!regionFree(tiles, cols, rows, idx, t.col, t.row, newCS, newRS)) {
|
||||||
|
this.setStatus("can't resize: would overlap or leave the grid", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.colspan = newCS
|
||||||
|
t.rowspan = newRS
|
||||||
|
this.dirty = true
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the base grid, refusing shrinks that would push a tile out of
|
||||||
|
* bounds rather than silently dropping it.
|
||||||
|
*/
|
||||||
|
resizeGrid(dCol: number, dRow: number): void {
|
||||||
|
const l = this.layout
|
||||||
|
if (!l) return
|
||||||
|
const [cols, rows] = this.gridDims()
|
||||||
|
const newCols = cols + dCol
|
||||||
|
const newRows = rows + dRow
|
||||||
|
if (newCols < 1 || newRows < 1 || newCols > this.maxGridDim || newRows > this.maxGridDim) return
|
||||||
|
for (const t of this.tiles()) {
|
||||||
|
const [cs, rs] = tileSpan(t)
|
||||||
|
if (t.col + cs > newCols || t.row + rs > newRows) {
|
||||||
|
this.setStatus("shrink blocked: a tile would fall outside the grid", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.grid = `${newCols}x${newRows}`
|
||||||
|
this.edCol = Math.min(this.edCol, newCols - 1)
|
||||||
|
this.edRow = Math.min(this.edRow, newRows - 1)
|
||||||
|
this.dirty = true
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize a tile by dragging to (col,row): spans are measured from the tile's
|
||||||
|
* origin, so a drag only ever grows toward the bottom-right. Returns whether
|
||||||
|
* anything changed, which the mouse handler uses to tell a drag from a click.
|
||||||
|
*/
|
||||||
|
dragResize(tileIdx: number, col: number, row: number): boolean {
|
||||||
|
const tiles = this.tiles()
|
||||||
|
const t = tiles[tileIdx]
|
||||||
|
if (!t) return false
|
||||||
|
const newCS = col - t.col + 1
|
||||||
|
const newRS = row - t.row + 1
|
||||||
|
if (newCS < 1 || newRS < 1) return false
|
||||||
|
const [cols, rows] = this.gridDims()
|
||||||
|
if (!regionFree(tiles, cols, rows, tileIdx, t.col, t.row, newCS, newRS)) return false
|
||||||
|
if (t.colspan === newCS && t.rowspan === newRS) return false
|
||||||
|
t.colspan = newCS
|
||||||
|
t.rowspan = newRS
|
||||||
|
this.edCol = col
|
||||||
|
this.edRow = row
|
||||||
|
this.dirty = true
|
||||||
|
this.notify()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- camera picker ----
|
||||||
|
|
||||||
|
/** "(empty)" followed by every enabled camera's name. */
|
||||||
|
pickerOptions(): string[] {
|
||||||
|
return [PICKER_EMPTY, ...this.cfg.cameras.filter((c) => !c.disabled).map((c) => c.name)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place the picker's choice at the cursor cell: retarget the tile there,
|
||||||
|
* create a 1x1 tile on an empty cell, or clear the cell for "(empty)".
|
||||||
|
*/
|
||||||
|
assignCamera(name: string): void {
|
||||||
|
const tiles = this.tiles()
|
||||||
|
const idx = this.tileIndexAtCursor()
|
||||||
|
if (!name) {
|
||||||
|
if (idx >= 0) {
|
||||||
|
tiles.splice(idx, 1)
|
||||||
|
this.dirty = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (idx >= 0) {
|
||||||
|
tiles[idx]!.camera = name
|
||||||
|
this.dirty = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tiles.length >= this.maxTiles) {
|
||||||
|
this.setStatus(`layout is full (${this.maxTiles} cameras max)`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tiles.push({ camera: name, col: this.edCol, row: this.edRow, colspan: 1, rowspan: 1 })
|
||||||
|
this.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
choosePickerOption(idx: number): void {
|
||||||
|
const opts = this.pickerOptions()
|
||||||
|
if (idx < 0 || idx >= opts.length) return
|
||||||
|
// Index 0 is the "(empty)" sentinel, which clears the cell.
|
||||||
|
this.assignCamera(idx === 0 ? "" : opts[idx]!)
|
||||||
|
this.go("layoutEdit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- set active ----
|
||||||
|
|
||||||
|
setActiveLayout(idx: number): void {
|
||||||
|
const l = this.cfg.layouts[idx]
|
||||||
|
if (!l) return
|
||||||
|
this.cfg.active_layout = l.name
|
||||||
|
this.dirty = true
|
||||||
|
this.setStatus(`active layout set to ${l.name}`)
|
||||||
|
this.go("menu")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bridge-backed actions ----
|
||||||
|
|
||||||
|
async save(): Promise<void> {
|
||||||
|
if (this.busy) return
|
||||||
|
this.busy = true
|
||||||
|
this.setStatus("saving…")
|
||||||
|
try {
|
||||||
|
const res = await applyConfig(this.cfg, { configPath: this.configPath })
|
||||||
|
if (!res.ok) {
|
||||||
|
this.setStatus(`save failed: ${res.error ?? "unknown error"}`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.dirty = false
|
||||||
|
this.daemonRunning = res.reloaded
|
||||||
|
this.setStatus(
|
||||||
|
res.reloaded ? "saved and reloaded the running daemon" : `saved to ${res.path ?? this.configPath}`,
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
this.setStatus(`save failed: ${errText(e)}`, true)
|
||||||
|
} finally {
|
||||||
|
this.busy = false
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover cameras from Protect and merge them into the catalog.
|
||||||
|
*
|
||||||
|
* `enable` is a comma-separated quality list to switch RTSP on for as a side
|
||||||
|
* effect. The merge mirrors the Go TUI's handleDiscover: match on Protect id
|
||||||
|
* first, then on name, so renaming a camera in Protect updates the existing
|
||||||
|
* entry instead of creating a duplicate. Nothing is written until Save.
|
||||||
|
*/
|
||||||
|
async runDiscover(enable: string): Promise<void> {
|
||||||
|
if (this.busy) return
|
||||||
|
this.busy = true
|
||||||
|
this.setStatus(enable ? "discovering and enabling RTSP (high+low)…" : "discovering…")
|
||||||
|
try {
|
||||||
|
const doc = await discover(enable, { configPath: this.configPath })
|
||||||
|
if (!doc.ok) {
|
||||||
|
this.setStatus(`discover failed: ${doc.error ?? "unknown error"}`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let added = 0
|
||||||
|
let updated = 0
|
||||||
|
for (const found of doc.cameras) {
|
||||||
|
const existing =
|
||||||
|
(found.id ? this.cfg.cameras.find((c) => c.id && c.id === found.id) : undefined) ??
|
||||||
|
cameraByName(this.cfg, found.name)
|
||||||
|
if (existing) {
|
||||||
|
existing.id = found.id
|
||||||
|
existing.name = found.name
|
||||||
|
existing.streams = found.streams
|
||||||
|
// Discovery supersedes the legacy single-URL field.
|
||||||
|
delete existing.rtsp
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
this.cfg.cameras.push({ id: found.id, name: found.name, streams: found.streams })
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.dirty = true
|
||||||
|
// Cameras Protect returned but that had no RTSP-enabled channel come
|
||||||
|
// back as warnings rather than cameras, so they count as skipped here.
|
||||||
|
const skipped = doc.warnings?.filter((w) => w.includes("no RTSP-enabled channel")).length ?? 0
|
||||||
|
const parts = [`${doc.cameras.length} usable`, `${added} new`, `${updated} updated`]
|
||||||
|
if (skipped) parts.push(`${skipped} without RTSP`)
|
||||||
|
if (doc.enabled) parts.push(`enabled ${doc.enabled} channels`)
|
||||||
|
this.setStatus(`discovered ${parts.join(", ")} — press Save to write`)
|
||||||
|
this.screen = "cameras"
|
||||||
|
this.cursor = 0
|
||||||
|
} catch (e) {
|
||||||
|
this.setStatus(`discover failed: ${errText(e)}`, true)
|
||||||
|
} finally {
|
||||||
|
this.busy = false
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestQuit(): void {
|
||||||
|
this.quitRequested = true
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errText(e: unknown): string {
|
||||||
|
if (e instanceof BridgeError) return e.message
|
||||||
|
return e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
33
tui/src/theme.ts
Normal file
33
tui/src/theme.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Colors, carried over from the lipgloss palette the Bubble Tea configurator
|
||||||
|
* used so the tool still looks like itself.
|
||||||
|
*
|
||||||
|
* The old styles used ANSI 256 indices (62, 205, 240, 35, 203…). Those are
|
||||||
|
* resolved here to hex, because opentui takes explicit colors and hex renders
|
||||||
|
* identically across the terminals this runs in — which in practice means an
|
||||||
|
* SSH session into the Pi.
|
||||||
|
*/
|
||||||
|
export const theme = {
|
||||||
|
/** Titles: the lipgloss 62 purple. */
|
||||||
|
title: "#5f5fd7",
|
||||||
|
/** Cursor / selection: 205 pink. */
|
||||||
|
cursor: "#ff5faf",
|
||||||
|
/** Secondary text: 240 grey. */
|
||||||
|
dim: "#585858",
|
||||||
|
/** Success and "already placed" markers: 35 green. */
|
||||||
|
ok: "#00af5f",
|
||||||
|
/** Errors: 203 red. */
|
||||||
|
err: "#ff5f5f",
|
||||||
|
/** Key names inside help lines: 81 cyan. */
|
||||||
|
key: "#5fd7ff",
|
||||||
|
/** Help text. */
|
||||||
|
help: "#c6c6c6",
|
||||||
|
/** Border of an unfocused panel. */
|
||||||
|
border: "#3a3a3a",
|
||||||
|
/** Text drawn on top of the cursor highlight. */
|
||||||
|
cursorText: "#000000",
|
||||||
|
/** A tile with a camera assigned. */
|
||||||
|
tile: "#87d7ff",
|
||||||
|
/** An empty grid cell. */
|
||||||
|
empty: "#444444",
|
||||||
|
} as const
|
||||||
118
tui/src/types.test.ts
Normal file
118
tui/src/types.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
availableQualities,
|
||||||
|
effectiveTiles,
|
||||||
|
layoutDimensions,
|
||||||
|
regionFree,
|
||||||
|
tileIndexAt,
|
||||||
|
tileSpan,
|
||||||
|
type Layout,
|
||||||
|
} from "./types.ts"
|
||||||
|
|
||||||
|
describe("layoutDimensions", () => {
|
||||||
|
test("parses COLSxROWS", () => {
|
||||||
|
expect(layoutDimensions({ name: "a", grid: "4x3" })).toEqual([4, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tolerates whitespace and case", () => {
|
||||||
|
expect(layoutDimensions({ name: "a", grid: " 2X2 " })).toEqual([2, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Go rejects these on save; the editor must stay usable rather than crash.
|
||||||
|
test.each(["", "4", "4x", "ax3", "0x3", "-1x2", "4x3x2"])("falls back to 1x1 on %p", (grid) => {
|
||||||
|
expect(layoutDimensions({ name: "a", grid })).toEqual([1, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tileSpan", () => {
|
||||||
|
test("normalizes absent and zero spans to 1", () => {
|
||||||
|
expect(tileSpan({ camera: "a", col: 0, row: 0 })).toEqual([1, 1])
|
||||||
|
expect(tileSpan({ camera: "a", col: 0, row: 0, colspan: 0, rowspan: 0 })).toEqual([1, 1])
|
||||||
|
expect(tileSpan({ camera: "a", col: 0, row: 0, colspan: 3, rowspan: 2 })).toEqual([3, 2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("effectiveTiles", () => {
|
||||||
|
test("normalizes spans without mutating the input", () => {
|
||||||
|
const l: Layout = { name: "a", grid: "2x2", tiles: [{ camera: "cam", col: 0, row: 0 }] }
|
||||||
|
expect(effectiveTiles(l)).toEqual([{ camera: "cam", col: 0, row: 0, colspan: 1, rowspan: 1 }])
|
||||||
|
expect(l.tiles![0]!.colspan).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("upgrades legacy row-major slots, skipping blanks", () => {
|
||||||
|
const l: Layout = { name: "a", grid: "2x2", slots: ["one", "", "three"] }
|
||||||
|
expect(effectiveTiles(l)).toEqual([
|
||||||
|
{ camera: "one", col: 0, row: 0, colspan: 1, rowspan: 1 },
|
||||||
|
{ camera: "three", col: 0, row: 1, colspan: 1, rowspan: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefers tiles over slots when both are present", () => {
|
||||||
|
const l: Layout = {
|
||||||
|
name: "a",
|
||||||
|
grid: "2x2",
|
||||||
|
tiles: [{ camera: "fromTiles", col: 1, row: 1 }],
|
||||||
|
slots: ["fromSlots"],
|
||||||
|
}
|
||||||
|
expect(effectiveTiles(l).map((t) => t.camera)).toEqual(["fromTiles"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tileIndexAt", () => {
|
||||||
|
const tiles = [
|
||||||
|
{ camera: "big", col: 0, row: 0, colspan: 3, rowspan: 2 },
|
||||||
|
{ camera: "small", col: 3, row: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
test("finds a tile through the whole span, not just its origin", () => {
|
||||||
|
expect(tileIndexAt(tiles, 0, 0)).toBe(0)
|
||||||
|
expect(tileIndexAt(tiles, 2, 1)).toBe(0)
|
||||||
|
expect(tileIndexAt(tiles, 3, 0)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns -1 on an empty cell", () => {
|
||||||
|
expect(tileIndexAt(tiles, 3, 1)).toBe(-1)
|
||||||
|
expect(tileIndexAt(tiles, 0, 2)).toBe(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("regionFree", () => {
|
||||||
|
const tiles = [
|
||||||
|
{ camera: "a", col: 0, row: 0, colspan: 2, rowspan: 1 },
|
||||||
|
{ camera: "b", col: 2, row: 0, colspan: 1, rowspan: 1 },
|
||||||
|
]
|
||||||
|
|
||||||
|
test("rejects regions leaving the grid", () => {
|
||||||
|
expect(regionFree(tiles, 4, 2, -1, 3, 0, 2, 1)).toBe(false)
|
||||||
|
expect(regionFree(tiles, 4, 2, -1, 0, 1, 1, 3)).toBe(false)
|
||||||
|
expect(regionFree(tiles, 4, 2, -1, -1, 0, 1, 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects regions overlapping another tile", () => {
|
||||||
|
expect(regionFree(tiles, 4, 2, -1, 1, 0, 1, 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores the excluded tile, so a tile can grow in place", () => {
|
||||||
|
// Tile "a" growing from 2x1 to 2x2 overlaps only itself.
|
||||||
|
expect(regionFree(tiles, 4, 2, 0, 0, 0, 2, 2)).toBe(true)
|
||||||
|
// ...but it cannot grow into "b".
|
||||||
|
expect(regionFree(tiles, 4, 2, 0, 0, 0, 3, 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts a free region", () => {
|
||||||
|
expect(regionFree(tiles, 4, 2, -1, 3, 0, 1, 2)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("availableQualities", () => {
|
||||||
|
const qualities = ["high", "medium", "low"]
|
||||||
|
|
||||||
|
test("lists only present streams, best first", () => {
|
||||||
|
expect(availableQualities({ name: "c", streams: { low: "u", high: "u" } }, qualities)).toEqual(["high", "low"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("is empty for a camera with no streams map", () => {
|
||||||
|
expect(availableQualities({ name: "c", rtsp: "rtsp://x" }, qualities)).toEqual([])
|
||||||
|
expect(availableQualities(undefined, qualities)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
190
tui/src/types.ts
Normal file
190
tui/src/types.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* The config schema as it crosses the bridge, plus the pure helpers that go
|
||||||
|
* with it.
|
||||||
|
*
|
||||||
|
* These mirror internal/config/config.go. The Go side stays authoritative:
|
||||||
|
* it validates and saves, so nothing here needs to re-implement validation.
|
||||||
|
* What we do need locally are the geometry helpers the editor consults on
|
||||||
|
* every keystroke — grid dimensions, tile spans, hit-testing — which would be
|
||||||
|
* absurd to shell out for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Quality = string
|
||||||
|
|
||||||
|
export interface Camera {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
rtsp?: string
|
||||||
|
streams?: Record<Quality, string>
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
camera: string
|
||||||
|
col: number
|
||||||
|
row: number
|
||||||
|
colspan?: number
|
||||||
|
rowspan?: number
|
||||||
|
/** Empty/absent means "the camera's best available stream". */
|
||||||
|
quality?: Quality
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Layout {
|
||||||
|
name: string
|
||||||
|
/** Base grid, "COLSxROWS" — e.g. "4x3". */
|
||||||
|
grid: string
|
||||||
|
tiles?: Tile[]
|
||||||
|
/** Legacy one-camera-per-cell model, upgraded by effectiveTiles(). */
|
||||||
|
slots?: string[]
|
||||||
|
protect_view?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Controller {
|
||||||
|
host?: string
|
||||||
|
username?: string
|
||||||
|
password_env?: string
|
||||||
|
verify_tls?: boolean
|
||||||
|
rtsp_port?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
controller: Controller
|
||||||
|
display: { width?: number; height?: number }
|
||||||
|
player: Record<string, unknown>
|
||||||
|
cameras: Camera[]
|
||||||
|
layouts: Layout[]
|
||||||
|
active_layout: string
|
||||||
|
view_refresh_seconds?: number
|
||||||
|
clock: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The document `rtsp-streamer config export` prints. */
|
||||||
|
export interface ExportDoc {
|
||||||
|
config_path: string
|
||||||
|
max_tiles: number
|
||||||
|
max_grid_dim: number
|
||||||
|
qualities: Quality[]
|
||||||
|
daemon_running: boolean
|
||||||
|
config: Config
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredCamera {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
streams: Record<Quality, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverDoc {
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
cameras: DiscoveredCamera[]
|
||||||
|
enabled: number
|
||||||
|
warnings?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyResult {
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
path?: string
|
||||||
|
reloaded: boolean
|
||||||
|
reload_error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- geometry helpers (ports of the config.Layout / config.Tile methods) ----
|
||||||
|
|
||||||
|
/** Tile spans with zero/absent values normalized to 1, as config.Tile.Span. */
|
||||||
|
export function tileSpan(t: Tile): [colspan: number, rowspan: number] {
|
||||||
|
return [Math.max(1, t.colspan ?? 1), Math.max(1, t.rowspan ?? 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a layout's base grid. Falls back to 1x1 on anything unparseable, which
|
||||||
|
* matches what the Go editor did — a broken `grid` string should not make the
|
||||||
|
* editor unusable, and Go rejects it on save anyway.
|
||||||
|
*/
|
||||||
|
export function layoutDimensions(l: Layout): [cols: number, rows: number] {
|
||||||
|
const parts = (l.grid ?? "").trim().toLowerCase().split("x")
|
||||||
|
if (parts.length !== 2) return [1, 1]
|
||||||
|
const cols = Number.parseInt(parts[0]!, 10)
|
||||||
|
const rows = Number.parseInt(parts[1]!, 10)
|
||||||
|
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) {
|
||||||
|
return [1, 1]
|
||||||
|
}
|
||||||
|
return [cols, rows]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout's tiles with spans normalized, upgrading a legacy row-major
|
||||||
|
* `slots` list to 1x1 tiles when `tiles` is empty. Mirrors
|
||||||
|
* config.Layout.EffectiveTiles.
|
||||||
|
*/
|
||||||
|
export function effectiveTiles(l: Layout): Tile[] {
|
||||||
|
if (l.tiles && l.tiles.length > 0) {
|
||||||
|
return l.tiles.map((t) => {
|
||||||
|
const [colspan, rowspan] = tileSpan(t)
|
||||||
|
return { ...t, colspan, rowspan }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const [cols] = layoutDimensions(l)
|
||||||
|
const out: Tile[] = []
|
||||||
|
;(l.slots ?? []).forEach((camera, i) => {
|
||||||
|
if (!camera) return
|
||||||
|
out.push({ camera, col: i % cols, row: Math.floor(i / cols), colspan: 1, rowspan: 1 })
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the tile covering (col,row), or -1. */
|
||||||
|
export function tileIndexAt(tiles: Tile[], col: number, row: number): number {
|
||||||
|
return tiles.findIndex((t) => {
|
||||||
|
const [cs, rs] = tileSpan(t)
|
||||||
|
return col >= t.col && col < t.col + cs && row >= t.row && row < t.row + rs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rectangle fits the grid and overlaps no tile except excludeIdx.
|
||||||
|
* Mirrors the Go editor's regionFree.
|
||||||
|
*/
|
||||||
|
export function regionFree(
|
||||||
|
tiles: Tile[],
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
excludeIdx: number,
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
colspan: number,
|
||||||
|
rowspan: number,
|
||||||
|
): boolean {
|
||||||
|
if (col < 0 || row < 0 || col + colspan > cols || row + rowspan > rows) return false
|
||||||
|
return tiles.every((t, i) => {
|
||||||
|
if (i === excludeIdx) return true
|
||||||
|
const [cs, rs] = tileSpan(t)
|
||||||
|
const overlaps = col < t.col + cs && col + colspan > t.col && row < t.row + rs && row + rowspan > t.row
|
||||||
|
return !overlaps
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cameraByName(cfg: Config, name: string): Camera | undefined {
|
||||||
|
return cfg.cameras.find((c) => c.name === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Qualities this camera actually has, best first. Mirrors AvailableQualities. */
|
||||||
|
export function availableQualities(cam: Camera | undefined, qualities: Quality[]): Quality[] {
|
||||||
|
if (!cam?.streams) return []
|
||||||
|
return qualities.filter((q) => !!cam.streams![q])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short label for a quality, for the compact tile footer. */
|
||||||
|
export function qualityAbbrev(q: Quality): string {
|
||||||
|
switch (q) {
|
||||||
|
case "high":
|
||||||
|
return "hi"
|
||||||
|
case "medium":
|
||||||
|
return "med"
|
||||||
|
case "low":
|
||||||
|
return "lo"
|
||||||
|
default:
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
}
|
||||||
191
tui/src/ui.test.ts
Normal file
191
tui/src/ui.test.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Render and mouse tests against a real (in-memory) renderer.
|
||||||
|
*
|
||||||
|
* The mouse cases matter most: pointer-to-cell mapping is the one bit of
|
||||||
|
* coordinate arithmetic left in the UI, and it is invisible until someone
|
||||||
|
* clicks the wrong tile over SSH.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
||||||
|
import { sampleDoc } from "./fixtures.ts"
|
||||||
|
import { Store, type Screen } from "./state.ts"
|
||||||
|
import { UI } from "./ui.ts"
|
||||||
|
|
||||||
|
let setup: TestRendererSetup
|
||||||
|
let store: Store
|
||||||
|
let ui: UI
|
||||||
|
|
||||||
|
/** Grid geometry, mirrored from ui.ts so tests fail loudly if it drifts. */
|
||||||
|
const CELL_H = 4
|
||||||
|
const CELL_W_MAX = 16
|
||||||
|
/** The mount box has padding 1, so content starts one row/column in. */
|
||||||
|
const PAD = 1
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 96 wide / 4 columns puts cellW at the 16-column cap, keeping the maths here
|
||||||
|
// simple and matching what the preview script shows.
|
||||||
|
setup = await createTestRenderer({ width: 96, height: 34 })
|
||||||
|
store = new Store()
|
||||||
|
store.hydrate(sampleDoc())
|
||||||
|
ui = new UI(setup.renderer, store)
|
||||||
|
store.subscribe(() => ui.render())
|
||||||
|
ui.render()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => setup.renderer.destroy())
|
||||||
|
|
||||||
|
async function frame(): Promise<string> {
|
||||||
|
ui.render()
|
||||||
|
await setup.flush()
|
||||||
|
return setup.captureCharFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads store.screen without letting TypeScript narrow it to whatever literal
|
||||||
|
* the test last assigned, so a screen change can be asserted.
|
||||||
|
*/
|
||||||
|
function currentScreen(): Screen {
|
||||||
|
return store.screen
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centre of a grid cell in absolute terminal coordinates. */
|
||||||
|
function cellCentre(col: number, row: number): [x: number, y: number] {
|
||||||
|
const titleRows = 2 // title line plus its bottom margin
|
||||||
|
return [
|
||||||
|
PAD + col * CELL_W_MAX + Math.floor(CELL_W_MAX / 2),
|
||||||
|
PAD + titleRows + row * CELL_H + Math.floor(CELL_H / 2),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("menu", () => {
|
||||||
|
test("lists every entry and marks a live daemon", async () => {
|
||||||
|
const out = await frame()
|
||||||
|
expect(out).toContain("rtsp-streamer configurator")
|
||||||
|
expect(out).toContain("Cameras")
|
||||||
|
expect(out).toContain("Discover + enable RTSP (hi+lo)")
|
||||||
|
expect(out).toContain("daemon live")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows an unsaved-changes hint once something is edited", async () => {
|
||||||
|
expect(await frame()).not.toContain("unsaved changes")
|
||||||
|
store.setActiveLayout(1)
|
||||||
|
expect(await frame()).toContain("unsaved changes")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cameras", () => {
|
||||||
|
test("shows available qualities, disabled state, and manual URLs", async () => {
|
||||||
|
store.screen = "cameras"
|
||||||
|
const out = await frame()
|
||||||
|
expect(out).toContain("Driveway")
|
||||||
|
expect(out).toContain("[high,low]")
|
||||||
|
expect(out).toContain("[disabled]")
|
||||||
|
expect(out).toContain("rtsp://10.0.0.9:554/stream1")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("layouts", () => {
|
||||||
|
test("marks the active layout and counts cameras", async () => {
|
||||||
|
store.screen = "layouts"
|
||||||
|
const out = await frame()
|
||||||
|
expect(out).toContain("main-plus")
|
||||||
|
expect(out).toContain("3 cameras")
|
||||||
|
expect(out).toContain("active")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("grid editor", () => {
|
||||||
|
beforeEach(() => store.openEditor(0)) // main-plus, 4x3
|
||||||
|
|
||||||
|
test("titles each tile and notes its span and quality", async () => {
|
||||||
|
const out = await frame()
|
||||||
|
expect(out).toContain('Edit "main-plus" — 4x3 grid, 3/16 cameras')
|
||||||
|
expect(out).toContain("Driveway")
|
||||||
|
expect(out).toContain("3x2 hi") // the hero tile's span and pinned quality
|
||||||
|
expect(out).toContain("auto") // Back Yard, unpinned
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a spanning tile is drawn as one box, not repeated per cell", async () => {
|
||||||
|
const out = await frame()
|
||||||
|
// "Driveway" spans 3x2 but is titled once.
|
||||||
|
expect(out.split("Driveway").length - 1).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resizing a tile is reflected in the next frame", async () => {
|
||||||
|
store.setCell(3, 0) // Front Door, 1x1
|
||||||
|
store.resizeTile(0, 1)
|
||||||
|
expect(await frame()).toContain("1x2 lo")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the grid shrinks to fit a narrow terminal", async () => {
|
||||||
|
setup.resize(50, 34)
|
||||||
|
const out = await frame()
|
||||||
|
// 4 columns must still fit within the width rather than overflowing.
|
||||||
|
for (const line of out.split("\n")) expect(line.trimEnd().length).toBeLessThanOrEqual(50)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("mouse", () => {
|
||||||
|
test("clicking a list row selects and activates it", async () => {
|
||||||
|
store.screen = "layouts"
|
||||||
|
await frame()
|
||||||
|
// Row 1 of the list: title plus its margin (2 rows), then one row each.
|
||||||
|
await setup.mockMouse.click(PAD + 4, PAD + 2 + 1)
|
||||||
|
// Activating resets `cursor` for the new screen, so the layout that was
|
||||||
|
// opened is what identifies the row that got clicked.
|
||||||
|
expect(currentScreen()).toBe("layoutEdit")
|
||||||
|
expect(store.editLayout).toBe(1)
|
||||||
|
expect(store.layout!.name).toBe("quad")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clicking an empty cell opens the picker for that cell", async () => {
|
||||||
|
store.openEditor(0)
|
||||||
|
await frame()
|
||||||
|
const [x, y] = cellCentre(3, 1) // the empty cell
|
||||||
|
await setup.mockMouse.click(x, y)
|
||||||
|
expect(store.screen).toBe("cameraPicker")
|
||||||
|
expect([store.edCol, store.edRow]).toEqual([3, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clicking inside a spanning tile targets that tile", async () => {
|
||||||
|
store.openEditor(0)
|
||||||
|
await frame()
|
||||||
|
const [x, y] = cellCentre(2, 1) // inside the 3x2 hero tile
|
||||||
|
await setup.mockMouse.click(x, y)
|
||||||
|
expect([store.edCol, store.edRow]).toEqual([2, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("dragging a tile resizes it instead of opening the picker", async () => {
|
||||||
|
store.openEditor(1) // quad 2x2, one 1x1 tile at (0,0)
|
||||||
|
await frame()
|
||||||
|
const from = cellCentre(0, 0)
|
||||||
|
const to = cellCentre(1, 1)
|
||||||
|
await setup.mockMouse.drag(from[0], from[1], to[0], to[1])
|
||||||
|
expect(store.layout!.tiles![0]).toMatchObject({ colspan: 2, rowspan: 2 })
|
||||||
|
// A drag must not fall through to the click handler.
|
||||||
|
expect(store.screen).toBe("layoutEdit")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a click outside the grid does not move the cell cursor", async () => {
|
||||||
|
store.openEditor(0)
|
||||||
|
store.setCell(1, 1)
|
||||||
|
await frame()
|
||||||
|
await setup.mockMouse.click(90, 30) // help text area, past the grid
|
||||||
|
expect([store.edCol, store.edRow]).toEqual([1, 1])
|
||||||
|
expect(store.screen).toBe("layoutEdit")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("camera picker", () => {
|
||||||
|
test("shows the grid for context and flags placed cameras", async () => {
|
||||||
|
store.openEditor(0)
|
||||||
|
store.setCell(3, 1)
|
||||||
|
store.screen = "cameraPicker"
|
||||||
|
const out = await frame()
|
||||||
|
expect(out).toContain("cell (col 3, row 1)")
|
||||||
|
expect(out).toContain("(empty)")
|
||||||
|
expect(out).toContain("already placed") // Driveway et al are in this layout
|
||||||
|
expect(out).not.toContain("Side Gate") // disabled
|
||||||
|
})
|
||||||
|
})
|
||||||
648
tui/src/ui.ts
Normal file
648
tui/src/ui.ts
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
/**
|
||||||
|
* The view layer: builds an opentui renderable tree from the store.
|
||||||
|
*
|
||||||
|
* Two things changed shape versus the lipgloss version this replaces:
|
||||||
|
*
|
||||||
|
* - The grid is real bordered boxes, absolutely positioned on a character
|
||||||
|
* lattice, instead of hand-assembled "+---+" strings. Spanning tiles are
|
||||||
|
* just a wider box, so the continuation-cell "·" markers are gone — a 2x2
|
||||||
|
* tile now looks like one 2x2 box.
|
||||||
|
* - Hit-testing is the framework's job. The list screens attach onMouseDown
|
||||||
|
* per row, so the header-offset arithmetic that internal/tui/mouse.go
|
||||||
|
* needed (headerRows, cellStride, blockH) is gone. The grid still converts
|
||||||
|
* pointer coordinates to a cell, but in one place, against the grid
|
||||||
|
* container's own screen position rather than a hardcoded layout guess.
|
||||||
|
*
|
||||||
|
* The tree is rebuilt on every state change. At this size (a few dozen boxes)
|
||||||
|
* that is far cheaper than the terminal repaint it triggers, and it keeps the
|
||||||
|
* Elm-ish "view is a pure function of state" property the Go version had.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
BoxRenderable,
|
||||||
|
Text,
|
||||||
|
TextAttributes,
|
||||||
|
type CliRenderer,
|
||||||
|
type MouseEvent,
|
||||||
|
type Renderable,
|
||||||
|
type VChild,
|
||||||
|
} from "@opentui/core"
|
||||||
|
import { MENU_ITEMS, MenuItem, PICKER_EMPTY, type Store } from "./state.ts"
|
||||||
|
import { theme } from "./theme.ts"
|
||||||
|
import { effectiveTiles, layoutDimensions, qualityAbbrev, tileIndexAt, tileSpan, type Layout } from "./types.ts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell geometry, in character units.
|
||||||
|
*
|
||||||
|
* Each cell is a self-contained box: two border rows plus two content rows, and
|
||||||
|
* boxes tile edge-to-edge without overlapping. Overlapping them by one cell to
|
||||||
|
* share a border looks tempting but renders wrong — a box draws its own corners,
|
||||||
|
* so every shared edge came out as `┌` where a lattice needs `┬`. Adjacent
|
||||||
|
* borders cost one column per cell and read cleanly as a card grid.
|
||||||
|
*/
|
||||||
|
const CELL_H = 4
|
||||||
|
const CELL_W_MIN = 8
|
||||||
|
const CELL_W_MAX = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anything that can appear as a child: a Box vnode, a Text vnode, a bare
|
||||||
|
* renderable, or nothing. Box() and Text() return different vnode types, so
|
||||||
|
* mixed children need the union rather than either one.
|
||||||
|
*/
|
||||||
|
type VNode = VChild
|
||||||
|
|
||||||
|
export class UI {
|
||||||
|
/** Persistent container; its subtree is swapped wholesale on each render. */
|
||||||
|
private readonly mount: BoxRenderable
|
||||||
|
/** The grid container, kept to map pointer coordinates onto cells. */
|
||||||
|
private gridBox: Renderable | null = null
|
||||||
|
|
||||||
|
// Drag state for tmux-style tile resizing.
|
||||||
|
private dragTile = -1
|
||||||
|
private dragging = false
|
||||||
|
private dragMoved = false
|
||||||
|
/**
|
||||||
|
* Set when a drag actually resized something, and cleared by the release that
|
||||||
|
* follows. Without it the release after a drag would be indistinguishable
|
||||||
|
* from a plain click and would pop the camera picker open every time.
|
||||||
|
*/
|
||||||
|
private justDragged = false
|
||||||
|
/**
|
||||||
|
* Whether the press that started this gesture landed on the grid. A click on
|
||||||
|
* a list row activates on press and re-renders, which can put the grid under
|
||||||
|
* the pointer in time to catch the release — this keeps that release from
|
||||||
|
* being read as a grid click.
|
||||||
|
*/
|
||||||
|
private pressedOnGrid = false
|
||||||
|
/**
|
||||||
|
* The grid's screen origin, captured on press and reused for the rest of the
|
||||||
|
* gesture.
|
||||||
|
*
|
||||||
|
* It cannot be read live during a drag: resizing notifies the store, which
|
||||||
|
* rebuilds the subtree, and the replacement grid box has no computed layout
|
||||||
|
* until the next frame — so screenX/screenY read as 0 and every subsequent
|
||||||
|
* drag step maps to the wrong cell (or out of bounds, and is dropped). The
|
||||||
|
* grid cannot move mid-gesture, so one reading at press time is correct.
|
||||||
|
*/
|
||||||
|
private gridOrigin: [x: number, y: number] | null = null
|
||||||
|
/**
|
||||||
|
* Suppresses the full subtree rebuild while a drag is in progress.
|
||||||
|
*
|
||||||
|
* The renderer captures the renderable a drag started on and routes the rest
|
||||||
|
* of the gesture to it. Rebuilding destroys that renderable, which silently
|
||||||
|
* ends the drag after the first resize — so during a drag the dragged tile's
|
||||||
|
* box is resized in place instead, and the rebuild happens once on release.
|
||||||
|
*/
|
||||||
|
private suppressRender = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly renderer: CliRenderer,
|
||||||
|
private readonly store: Store,
|
||||||
|
) {
|
||||||
|
// A real renderable, not a vnode: render() calls getChildren()/remove() on
|
||||||
|
// it every frame, and a vnode only queues those for instantiation time.
|
||||||
|
this.mount = new BoxRenderable(renderer, {
|
||||||
|
id: "mount",
|
||||||
|
flexDirection: "column",
|
||||||
|
padding: 1,
|
||||||
|
width: "100%",
|
||||||
|
})
|
||||||
|
renderer.root.add(this.mount)
|
||||||
|
this.installMouse()
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): void {
|
||||||
|
if (this.suppressRender) return
|
||||||
|
// Swap the subtree. destroyRecursively releases the native buffers behind
|
||||||
|
// each renderable; without it a long editing session leaks them.
|
||||||
|
//
|
||||||
|
// It also detaches the child from this box, so calling remove() first is
|
||||||
|
// not just redundant — the destroy then tries to detach an already-orphaned
|
||||||
|
// renderable, throws, and leaves the tree in a state where mouse
|
||||||
|
// hit-testing silently stops reaching the new subtree.
|
||||||
|
for (const child of [...this.mount.getChildren()]) {
|
||||||
|
child.destroyRecursively()
|
||||||
|
}
|
||||||
|
this.gridBox = null
|
||||||
|
this.mount.add(this.screen())
|
||||||
|
// The grid needs its computed screen position for pointer mapping, so
|
||||||
|
// resolve it after the tree is attached and laid out.
|
||||||
|
this.gridBox = this.mount.findDescendantById("grid") ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private screen(): VNode {
|
||||||
|
const s = this.store
|
||||||
|
switch (s.screen) {
|
||||||
|
case "menu":
|
||||||
|
return this.menuScreen()
|
||||||
|
case "cameras":
|
||||||
|
return this.camerasScreen()
|
||||||
|
case "layouts":
|
||||||
|
return this.layoutsScreen()
|
||||||
|
case "layoutEdit":
|
||||||
|
return this.layoutEditScreen()
|
||||||
|
case "cameraPicker":
|
||||||
|
return this.cameraPickerScreen()
|
||||||
|
case "setActive":
|
||||||
|
return this.setActiveScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shared chrome ----
|
||||||
|
|
||||||
|
private title(text: string): VNode {
|
||||||
|
return Box(
|
||||||
|
{ paddingX: 1, marginBottom: 1 },
|
||||||
|
Text({ content: text, fg: theme.title, attributes: TextAttributes.BOLD }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A help line: key names accented, descriptions dim. */
|
||||||
|
private help(pairs: [key: string, desc: string][], label = ""): VNode {
|
||||||
|
const items: VNode[] = []
|
||||||
|
if (label) items.push(Text({ content: label, fg: theme.dim }))
|
||||||
|
for (const [key, desc] of pairs) {
|
||||||
|
items.push(
|
||||||
|
Box(
|
||||||
|
{ flexDirection: "row" },
|
||||||
|
Text({ content: key, fg: theme.key, attributes: TextAttributes.BOLD }),
|
||||||
|
Text({ content: ` ${desc}`, fg: theme.help }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Box({ flexDirection: "row", gap: 2, flexWrap: "wrap" }, ...items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status line, plus an unsaved-changes hint so Save is never a surprise. */
|
||||||
|
private footer(): VNode {
|
||||||
|
const s = this.store
|
||||||
|
const rows: VNode[] = []
|
||||||
|
if (s.status) {
|
||||||
|
rows.push(Text({ content: s.status, fg: s.isError ? theme.err : theme.ok }))
|
||||||
|
}
|
||||||
|
if (s.dirty) {
|
||||||
|
rows.push(Text({ content: "unsaved changes — press S to save", fg: theme.dim }))
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return Box({})
|
||||||
|
return Box({ flexDirection: "column", marginTop: 1 }, ...rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A selectable list. Each row is its own box with a mouse handler, so
|
||||||
|
* clicking a row selects and activates it exactly like pressing enter.
|
||||||
|
*/
|
||||||
|
private list(rows: VNode[], onActivate: (idx: number) => void): VNode {
|
||||||
|
const s = this.store
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return Box({ flexDirection: "column" }, Text({ content: " (nothing here yet)", fg: theme.dim }))
|
||||||
|
}
|
||||||
|
return Box(
|
||||||
|
{ flexDirection: "column" },
|
||||||
|
...rows.map((row, i) =>
|
||||||
|
Box(
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: i === s.cursor ? theme.cursor : undefined,
|
||||||
|
onMouseDown: (e: MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
s.cursor = i
|
||||||
|
onActivate(i)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Text({
|
||||||
|
content: i === s.cursor ? "▸ " : " ",
|
||||||
|
fg: i === s.cursor ? theme.cursorText : theme.dim,
|
||||||
|
bg: i === s.cursor ? theme.cursor : undefined,
|
||||||
|
}),
|
||||||
|
row,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row text, inverted while selected so it stays legible on the highlight. */
|
||||||
|
private rowText(content: string, idx: number, fg: string = theme.help): VNode {
|
||||||
|
const selected = idx === this.store.cursor
|
||||||
|
return Text({
|
||||||
|
content,
|
||||||
|
fg: selected ? theme.cursorText : fg,
|
||||||
|
bg: selected ? theme.cursor : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- menu ----
|
||||||
|
|
||||||
|
private menuScreen(): VNode {
|
||||||
|
const s = this.store
|
||||||
|
const rows = MENU_ITEMS.map((item, i) => {
|
||||||
|
const parts: VNode[] = [this.rowText(item, i, i === MenuItem.Quit ? theme.dim : theme.help)]
|
||||||
|
if (i === MenuItem.Save && s.dirty) {
|
||||||
|
parts.push(this.rowText(" (unsaved changes)", i, theme.dim))
|
||||||
|
}
|
||||||
|
if (i === MenuItem.Save && s.daemonRunning) {
|
||||||
|
parts.push(this.rowText(" ● daemon live", i, theme.ok))
|
||||||
|
}
|
||||||
|
return Box({ flexDirection: "row" }, ...parts)
|
||||||
|
})
|
||||||
|
return Box(
|
||||||
|
{ flexDirection: "column" },
|
||||||
|
this.title("rtsp-streamer configurator"),
|
||||||
|
this.list(rows, (i) => this.activateMenu(i)),
|
||||||
|
Box({ marginTop: 1 }, this.help([["↑/↓", "move"], ["enter", "select"], ["q", "quit"]])),
|
||||||
|
this.footer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
activateMenu(idx: number): void {
|
||||||
|
const s = this.store
|
||||||
|
switch (idx) {
|
||||||
|
case MenuItem.Cameras:
|
||||||
|
s.go("cameras")
|
||||||
|
break
|
||||||
|
case MenuItem.Layouts:
|
||||||
|
s.go("layouts")
|
||||||
|
break
|
||||||
|
case MenuItem.SetActive:
|
||||||
|
s.go("setActive")
|
||||||
|
break
|
||||||
|
case MenuItem.Discover:
|
||||||
|
void s.runDiscover("")
|
||||||
|
break
|
||||||
|
case MenuItem.DiscoverEnable:
|
||||||
|
void s.runDiscover("high,low")
|
||||||
|
break
|
||||||
|
case MenuItem.Save:
|
||||||
|
void s.save()
|
||||||
|
break
|
||||||
|
case MenuItem.Quit:
|
||||||
|
s.requestQuit()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cameras ----
|
||||||
|
|
||||||
|
private camerasScreen(): VNode {
|
||||||
|
const s = this.store
|
||||||
|
const rows = s.cfg.cameras.map((c, i) => {
|
||||||
|
const quals = Object.keys(c.streams ?? {})
|
||||||
|
const detail = quals.length > 0 ? `[${quals.join(",")}]` : truncate(c.rtsp ?? "(no stream)", 48)
|
||||||
|
const parts: VNode[] = [
|
||||||
|
this.rowText(pad(c.name, 24), i),
|
||||||
|
this.rowText(` ${detail}`, i, theme.dim),
|
||||||
|
]
|
||||||
|
if (c.disabled) parts.push(this.rowText(" [disabled]", i, theme.dim))
|
||||||
|
return Box({ flexDirection: "row" }, ...parts)
|
||||||
|
})
|
||||||
|
return Box(
|
||||||
|
{ flexDirection: "column" },
|
||||||
|
this.title(`Cameras (${s.cfg.cameras.length})`),
|
||||||
|
// Clicking a camera row toggles it; there is nothing to drill into.
|
||||||
|
this.list(rows, () => s.toggleCameraDisabled()),
|
||||||
|
Box(
|
||||||
|
{ marginTop: 1 },
|
||||||
|
this.help([["d", "toggle disabled"], ["x", "delete"], ["esc", "back"]]),
|
||||||
|
),
|
||||||
|
this.footer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- layouts ----
|
||||||
|
|
||||||
|
private layoutsScreen(): VNode {
|
||||||
|
const s = this.store
|
||||||
|
const rows = s.cfg.layouts.map((l, i) => {
|
||||||
|
const parts: VNode[] = [
|
||||||
|
this.rowText(pad(l.name, 16), i),
|
||||||
|
this.rowText(pad(l.grid, 6), i, theme.dim),
|
||||||
|
this.rowText(`${effectiveTiles(l).length} cameras`, i, theme.dim),
|
||||||
|
]
|
||||||
|
if (l.name === s.cfg.active_layout) parts.push(this.rowText(" ●active", i, theme.ok))
|
||||||
|
if (l.protect_view) parts.push(this.rowText(` ↺ ${l.protect_view}`, i, theme.dim))
|
||||||
|
return Box({ flexDirection: "row", gap: 1 }, ...parts)
|
||||||
|
})
|
||||||
|
return Box(
|
||||||
|
{ flexDirection: "column" },
|
||||||
|
this.title("Layouts"),
|
||||||
|
this.list(rows, (i) => s.openEditor(i)),
|
||||||
|
Box({ marginTop: 1 }, this.help([["enter", "edit"], ["esc", "back"]])),
|
||||||
|
this.footer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- set active ----
|
||||||
|
|
||||||
|
private setActiveScreen(): VNode {
|
||||||
|
const s = this.store
|
||||||
|
const rows = s.cfg.layouts.map((l, i) => {
|
||||||
|
const parts: VNode[] = [this.rowText(pad(l.name, 16), i), this.rowText(l.grid, i, theme.dim)]
|
||||||
|
if (l.name === s.cfg.active_layout) parts.push(this.rowText(" ●current", i, theme.ok))
|
||||||
|
return Box({ flexDirection: "row", gap: 1 }, ...parts)
|
||||||
|
})
|
||||||
|
return Box(
|
||||||
|
{ flexDirection: "column" },
|
||||||
|
this.title("Set active layout"),
|
||||||
|
this.list(rows, (i) => s.setActiveLayout(i)),
|
||||||
|
Box({ marginTop: 1 }, this.help([["enter", "select"], ["esc", "back"]])),
|
||||||
|
this.footer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- layout editor ----
|
||||||
|
|
||||||
|
private layoutEditScreen(): VNode {
|
||||||
|
const s = this.store
|
||||||
|
const l = s.layout
|
||||||
|
if (!l) return Box({}, Text({ content: "no such layout", fg: theme.err }))
|
||||||
|
const [cols, rows] = s.gridDims()
|
||||||
|
const tiles = l.tiles ?? []
|
||||||
|
|
||||||
|
return Box(
|
||||||
|
{ flexDirection: "column" },
|
||||||
|
this.title(`Edit "${l.name}" — ${cols}x${rows} grid, ${tiles.length}/${s.maxTiles} cameras`),
|
||||||
|
this.grid(l, { interactive: true }),
|
||||||
|
Box(
|
||||||
|
{ flexDirection: "column", marginTop: 1, gap: 0 },
|
||||||
|
this.help([["click", "assign cell"], ["drag a tile", "resize"]], "mouse"),
|
||||||
|
this.help([["←↑↓→/hjkl", "move"], ["enter", "assign"], ["c", "clear"], ["q", "quality"]]),
|
||||||
|
this.help([["L/H", "wider/narrower"], ["J/K", "taller/shorter"]], "resize tile"),
|
||||||
|
this.help([["] [", "cols"], ["} {", "rows"], ["esc", "back"]], "base grid"),
|
||||||
|
),
|
||||||
|
this.footer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell width for a grid of `cols` columns, fitted to the terminal so an 8-wide
|
||||||
|
* grid does not overflow. Shared by the renderer and the mouse handlers, which
|
||||||
|
* must agree on the lattice exactly.
|
||||||
|
*/
|
||||||
|
private cellWidth(cols: number): number {
|
||||||
|
return clamp(Math.floor((this.renderer.width - 4) / cols), CELL_W_MIN, CELL_W_MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw the base grid: one absolutely-positioned box per cell, with tiles
|
||||||
|
* drawn over the empty cells so a spanning tile reads as a single box.
|
||||||
|
*/
|
||||||
|
private grid(l: Layout, opts: { interactive: boolean }): VNode {
|
||||||
|
const s = this.store
|
||||||
|
const [cols, rows] = layoutDimensions(l)
|
||||||
|
const tiles = effectiveTiles(l)
|
||||||
|
const cellW = this.cellWidth(cols)
|
||||||
|
|
||||||
|
const children: VNode[] = []
|
||||||
|
|
||||||
|
// Empty cells first, so the cursor highlight paints over them.
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
if (tileIndexAt(tiles, c, r) >= 0) continue
|
||||||
|
const isCursor = opts.interactive && c === s.edCol && r === s.edRow
|
||||||
|
children.push(
|
||||||
|
Box({
|
||||||
|
position: "absolute",
|
||||||
|
left: c * cellW,
|
||||||
|
top: r * CELL_H,
|
||||||
|
width: cellW,
|
||||||
|
height: CELL_H,
|
||||||
|
borderStyle: "single",
|
||||||
|
borderColor: isCursor ? theme.cursor : theme.empty,
|
||||||
|
zIndex: isCursor ? 2 : 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tiles.forEach((t, i) => {
|
||||||
|
const [cs, rs] = tileSpan(t)
|
||||||
|
// A spanning tile is "under the cursor" anywhere in its rectangle.
|
||||||
|
const isCursor =
|
||||||
|
opts.interactive &&
|
||||||
|
s.edCol >= t.col &&
|
||||||
|
s.edCol < t.col + cs &&
|
||||||
|
s.edRow >= t.row &&
|
||||||
|
s.edRow < t.row + rs
|
||||||
|
const width = cs * cellW
|
||||||
|
const label = t.camera || "(no cam)"
|
||||||
|
|
||||||
|
const notes: string[] = []
|
||||||
|
if (cs > 1 || rs > 1) notes.push(`${cs}x${rs}`)
|
||||||
|
notes.push(t.quality ? qualityAbbrev(t.quality) : "auto")
|
||||||
|
|
||||||
|
children.push(
|
||||||
|
Box(
|
||||||
|
{
|
||||||
|
// Tagged so a drag can resize this box in place; the index matches
|
||||||
|
// the store's tile index because effectiveTiles preserves order.
|
||||||
|
...(opts.interactive ? { id: `tile-${i}` } : {}),
|
||||||
|
position: "absolute",
|
||||||
|
left: t.col * cellW,
|
||||||
|
top: t.row * CELL_H,
|
||||||
|
width,
|
||||||
|
height: rs * CELL_H,
|
||||||
|
borderStyle: isCursor ? "double" : "single",
|
||||||
|
borderColor: isCursor ? theme.cursor : theme.tile,
|
||||||
|
title: truncate(label, Math.max(1, width - 4)),
|
||||||
|
titleColor: isCursor ? theme.cursor : theme.tile,
|
||||||
|
zIndex: isCursor ? 2 : 1,
|
||||||
|
paddingX: 1,
|
||||||
|
},
|
||||||
|
Text({ content: truncate(notes.join(" "), Math.max(1, width - 3)), fg: theme.dim }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// No mouse handlers here: they live on the persistent mount box, because
|
||||||
|
// this one is destroyed and rebuilt on every state change. See installMouse.
|
||||||
|
return Box(
|
||||||
|
{
|
||||||
|
// id lets the mouse handlers find this box to read its screen origin.
|
||||||
|
id: "grid",
|
||||||
|
width: cols * cellW,
|
||||||
|
height: rows * CELL_H,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
...children,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- camera picker ----
|
||||||
|
|
||||||
|
private cameraPickerScreen(): VNode {
|
||||||
|
const s = this.store
|
||||||
|
const l = s.layout
|
||||||
|
if (!l) return Box({}, Text({ content: "no such layout", fg: theme.err }))
|
||||||
|
const used = s.usedCameras()
|
||||||
|
const opts = s.pickerOptions()
|
||||||
|
|
||||||
|
const rows = opts.map((name, i) => {
|
||||||
|
if (i === 0) return Box({ flexDirection: "row" }, this.rowText(PICKER_EMPTY, i, theme.dim))
|
||||||
|
const parts: VNode[] = [this.rowText(name, i)]
|
||||||
|
if (used.has(name)) parts.push(this.rowText(" ● already placed", i, theme.ok))
|
||||||
|
return Box({ flexDirection: "row" }, ...parts)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Box(
|
||||||
|
{ flexDirection: "column" },
|
||||||
|
this.title(`Assign to "${l.name}" — cell (col ${s.edCol}, row ${s.edRow})`),
|
||||||
|
// Show the grid while choosing, so what is already placed stays visible.
|
||||||
|
this.grid(l, { interactive: false }),
|
||||||
|
Box({ marginTop: 1 }, ...[this.list(rows, (i) => s.choosePickerOption(i))]),
|
||||||
|
Box(
|
||||||
|
{ marginTop: 1 },
|
||||||
|
this.help([["enter", "choose"], [PICKER_EMPTY, "clears cell"], ["esc", "back"]]),
|
||||||
|
),
|
||||||
|
this.footer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- grid mouse handling ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the grid's mouse handlers on the persistent mount box, once.
|
||||||
|
*
|
||||||
|
* They cannot live on the grid box itself. A drag is routed to the renderable
|
||||||
|
* the press landed on, and resizing a tile notifies the store, which rebuilds
|
||||||
|
* the subtree and destroys that renderable — so every drag event after the
|
||||||
|
* first resize was delivered to a dead box and dropped. The mount box outlives
|
||||||
|
* every render, so it keeps receiving the whole gesture.
|
||||||
|
*
|
||||||
|
* The cost is that these see presses anywhere on screen, so each one checks
|
||||||
|
* that the editor is open and that the pointer is actually over the grid.
|
||||||
|
* List rows stop propagation, so they never reach here.
|
||||||
|
*/
|
||||||
|
private installMouse(): void {
|
||||||
|
this.mount.onMouseDown = (e: MouseEvent) => this.gridMouseDown(e)
|
||||||
|
this.mount.onMouseDrag = (e: MouseEvent) => this.gridMouseDrag(e)
|
||||||
|
this.mount.onMouseUp = (e: MouseEvent) => this.gridMouseUp(e)
|
||||||
|
// The final leg of a drag arrives here, not as another drag event, so it
|
||||||
|
// has to be applied before the drag state is torn down.
|
||||||
|
this.mount.onMouseDragEnd = (e: MouseEvent) => {
|
||||||
|
this.gridMouseDrag(e)
|
||||||
|
const moved = this.dragMoved
|
||||||
|
this.justDragged = moved
|
||||||
|
this.endDrag()
|
||||||
|
// Catch up on the rebuild that was suppressed for the duration of the
|
||||||
|
// drag, so labels, spans and the cursor highlight are all consistent.
|
||||||
|
if (moved) this.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The grid's dimensions for the layout currently being edited, or null. */
|
||||||
|
private gridGeometry(): { cols: number; rows: number; cellW: number } | null {
|
||||||
|
const l = this.store.layout
|
||||||
|
if (!l || this.store.screen !== "layoutEdit") return null
|
||||||
|
const [cols, rows] = layoutDimensions(l)
|
||||||
|
return { cols, rows, cellW: this.cellWidth(cols) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map absolute pointer coordinates onto a grid cell, relative to the grid's
|
||||||
|
* origin as captured at press time (see gridOrigin).
|
||||||
|
*/
|
||||||
|
private cellAt(e: MouseEvent, cols: number, rows: number, cellW: number): [col: number, row: number] | null {
|
||||||
|
const origin = this.gridOrigin
|
||||||
|
if (!origin) return null
|
||||||
|
const col = Math.floor((e.x - origin[0]) / cellW)
|
||||||
|
const row = Math.floor((e.y - origin[1]) / CELL_H)
|
||||||
|
if (col < 0 || col >= cols || row < 0 || row >= rows) return null
|
||||||
|
return [col, row]
|
||||||
|
}
|
||||||
|
|
||||||
|
private gridMouseDown(e: MouseEvent): void {
|
||||||
|
const geo = this.gridGeometry()
|
||||||
|
if (!geo) return
|
||||||
|
// Read the origin from the live grid box while its layout is current, and
|
||||||
|
// hold it for the rest of the gesture.
|
||||||
|
const box = this.gridBox
|
||||||
|
if (!box) return
|
||||||
|
this.gridOrigin = [box.screenX, box.screenY]
|
||||||
|
|
||||||
|
const cell = this.cellAt(e, geo.cols, geo.rows, geo.cellW)
|
||||||
|
if (!cell) return
|
||||||
|
e.stopPropagation()
|
||||||
|
this.justDragged = false
|
||||||
|
this.pressedOnGrid = true
|
||||||
|
this.store.setCell(cell[0], cell[1])
|
||||||
|
// Only a cell that already holds a tile can be dragged; dragging an empty
|
||||||
|
// cell would have nothing to resize.
|
||||||
|
this.dragTile = this.store.tileIndexAtCursor()
|
||||||
|
this.dragging = this.dragTile >= 0
|
||||||
|
this.dragMoved = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private gridMouseDrag(e: MouseEvent): void {
|
||||||
|
if (!this.dragging) return
|
||||||
|
const geo = this.gridGeometry()
|
||||||
|
if (!geo) return
|
||||||
|
const cell = this.cellAt(e, geo.cols, geo.rows, geo.cellW)
|
||||||
|
if (!cell) return
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// Apply the resize without rebuilding the subtree, then reflect it by
|
||||||
|
// resizing the live box. Rebuilding here would destroy the renderable the
|
||||||
|
// renderer captured for this drag and the gesture would end silently.
|
||||||
|
this.suppressRender = true
|
||||||
|
const changed = this.store.dragResize(this.dragTile, cell[0], cell[1])
|
||||||
|
this.suppressRender = false
|
||||||
|
if (!changed) return
|
||||||
|
this.dragMoved = true
|
||||||
|
|
||||||
|
const tile = this.store.layout?.tiles?.[this.dragTile]
|
||||||
|
const box = this.mount.findDescendantById(`tile-${this.dragTile}`)
|
||||||
|
if (tile && box) {
|
||||||
|
const [cs, rs] = tileSpan(tile)
|
||||||
|
box.width = cs * geo.cellW
|
||||||
|
box.height = rs * CELL_H
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private gridMouseUp(e: MouseEvent): void {
|
||||||
|
// A drag may have been torn down by onMouseDragEnd already, so consult
|
||||||
|
// justDragged as well as the live drag state.
|
||||||
|
const wasDrag = this.justDragged || (this.dragging && this.dragMoved)
|
||||||
|
const pressed = this.pressedOnGrid
|
||||||
|
this.justDragged = false
|
||||||
|
this.pressedOnGrid = false
|
||||||
|
this.endDrag()
|
||||||
|
// Ignore a release whose press went somewhere else — it is the tail of
|
||||||
|
// someone else's click, not a click on this grid.
|
||||||
|
if (wasDrag || !pressed) return
|
||||||
|
const geo = this.gridGeometry()
|
||||||
|
if (!geo) return
|
||||||
|
const cell = this.cellAt(e, geo.cols, geo.rows, geo.cellW)
|
||||||
|
if (!cell) return
|
||||||
|
e.stopPropagation()
|
||||||
|
// A plain click (no drag) opens the picker for that cell.
|
||||||
|
this.store.setCell(cell[0], cell[1])
|
||||||
|
this.store.go("cameraPicker")
|
||||||
|
}
|
||||||
|
|
||||||
|
private endDrag(): void {
|
||||||
|
this.dragging = false
|
||||||
|
this.dragMoved = false
|
||||||
|
this.dragTile = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- small string helpers ----
|
||||||
|
|
||||||
|
function clamp(n: number, lo: number, hi: number): number {
|
||||||
|
return Math.min(Math.max(n, lo), hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Left-justify to n columns. Counts code points, not UTF-16 units. */
|
||||||
|
function pad(s: string, n: number): string {
|
||||||
|
const chars = [...s]
|
||||||
|
if (chars.length >= n) return chars.slice(0, n).join("")
|
||||||
|
return s + " ".repeat(n - chars.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorten to at most n columns, with an ellipsis when it clips. */
|
||||||
|
export function truncate(s: string, n: number): string {
|
||||||
|
const chars = [...s]
|
||||||
|
if (chars.length <= n) return s
|
||||||
|
if (n <= 1) return chars.slice(0, n).join("")
|
||||||
|
return chars.slice(0, n - 1).join("") + "…"
|
||||||
|
}
|
||||||
30
tui/tsconfig.json
Normal file
30
tui/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"types": ["bun"],
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user