diff --git a/Makefile b/Makefile index 632c88d..5f06137 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,13 @@ BINARY := rtsp-streamer PKG := ./cmd/rtsp-streamer 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`). VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) 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`. PREFIX ?= /usr/local DESTBIN := $(PREFIX)/bin/$(BINARY) +DESTTUI := $(PREFIX)/bin/$(TUIBINARY) # Use sudo automatically when not already root, so `make install` / # `make deploy` work whether or not you prefix them with 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 ## build: compile for the host platform (native arch) @@ -35,11 +45,42 @@ pi: pi32: 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 -## (auto-sudo). This is the location the running daemon uses. +## tui-deps: install the configurator's node_modules (needs Bun) +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 $(SUDO) install -m 0755 $(BINDIR)/$(BINARY) $(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 ## 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" @echo "run 'rtsp-streamer status' to check" -## uninstall: remove the installed binary +## uninstall: remove the installed binaries uninstall: - $(SUDO) rm -f $(DESTBIN) - @echo "removed $(DESTBIN)" + $(SUDO) rm -f $(DESTBIN) $(DESTTUI) + @echo "removed $(DESTBIN) and $(DESTTUI)" -## test: run unit tests +## test: run Go unit tests 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: 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 - 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: diff --git a/README.md b/README.md index fff2c2e..5cd4d60 100644 --- a/README.md +++ b/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, 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 > (`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`: @@ -71,6 +85,21 @@ make deploy # install + restart the kiosk session 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 ```sh @@ -97,23 +126,35 @@ may not overlap. See `main-plus` in [`config.example.yaml`](config.example.yaml) ### 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. -- **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. -- **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. -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: ``` -+----------+----------+----------+----------+ -|Front Door| · | · |Driveway | -|3x3 | · | · | | -+----------+----------+----------+----------+ -| · | · | · |Back Yard | -... +┌─Driveway─────────────────────────────────────┐┌─Front Door───┐ +│ 3x2 hi ││ lo │ +│ ││ │ +│ │└──────────────┘ +│ │┌──────────────┐ +│ ││ │ +└──────────────────────────────────────────────┘└──────────────┘ +┌──────────────┐┌──────────────┐┌──────────────┐┌─Back Yard────┐ +│ ││ ││ ││ auto │ +└──────────────┘└──────────────┘└──────────────┘└──────────────┘ ``` - **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 Elsewhere: arrows / `j`/`k` move (or click), `enter` selects, `esc` back, `q` quits. +`S` saves from any screen. ### Stream quality per tile @@ -155,6 +197,44 @@ wall — stream URLs, tile geometry, player settings — against what's already running and leaves the streams untouched when nothing material changed, so saving an unrelated edit never blanks the screen. +### 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 An optional always-on-top clock can be rendered at a screen edge: @@ -282,6 +362,14 @@ Then `sudo reboot` and the wall comes up on boot. 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 *without* `sudo` so `go build` keeps your `PATH`), then runs `rtsp-streamer restart`, which tells the running daemon to **re-exec itself in @@ -306,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 ``` -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 `isRtspEnabled` flag. Plain `discover` (no flag) never modifies your controller; it just skips cameras with no RTSP-enabled channel and reports which ones. @@ -399,6 +487,22 @@ pegged, work through: - **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). +- **`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 - **Unofficial API.** UniFi Protect has no public API; the local endpoints used @@ -431,6 +535,7 @@ internal/player/ one supervised mpv per stream, JSON IPC, restart/backoff internal/compositor/ sway control + tile geometry internal/daemon/ orchestrator + control socket + health loop 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 ``` diff --git a/cmd/rtsp-streamer/bridge.go b/cmd/rtsp-streamer/bridge.go new file mode 100644 index 0000000..5ea0e84 --- /dev/null +++ b/cmd/rtsp-streamer/bridge.go @@ -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) +} diff --git a/cmd/rtsp-streamer/commands.go b/cmd/rtsp-streamer/commands.go index ad49bb2..b378dd6 100644 --- a/cmd/rtsp-streamer/commands.go +++ b/cmd/rtsp-streamer/commands.go @@ -59,10 +59,16 @@ func daemonCmd() *cobra.Command { // discoverCmd queries UniFi Protect and merges cameras into the config. func discoverCmd() *cobra.Command { var enableRTSP string + var asJSON bool c := &cobra.Command{ Use: "discover", Short: "Discover cameras from UniFi Protect and update the config", 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) if err != nil { 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().BoolVar(&asJSON, "json", false, "print results as JSON without saving the config") 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 { - return &cobra.Command{ + var legacy bool + c := &cobra.Command{ Use: "tui", Aliases: []string{"config-ui"}, 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 { - 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. @@ -514,7 +542,7 @@ func configCmd() *cobra.Command { }, } - c.AddCommand(initCmd, pathCmd, editCmd) + c.AddCommand(initCmd, pathCmd, editCmd, configExportCmd(), configApplyCmd()) return c } diff --git a/cmd/rtsp-streamer/main.go b/cmd/rtsp-streamer/main.go index 483f18b..b0fc693 100644 --- a/cmd/rtsp-streamer/main.go +++ b/cmd/rtsp-streamer/main.go @@ -4,6 +4,7 @@ package main import ( + "errors" "fmt" "io" "log/slog" @@ -52,7 +53,11 @@ func main() { ) 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) } } diff --git a/cmd/rtsp-streamer/tuilaunch.go b/cmd/rtsp-streamer/tuilaunch.go new file mode 100644 index 0000000..1cd26bb --- /dev/null +++ b/cmd/rtsp-streamer/tuilaunch.go @@ -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 +} diff --git a/config.example.yaml b/config.example.yaml index 4fda52d..dfce949 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -55,7 +55,7 @@ cameras: # 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 # 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: # Simple even grid: four 1x1 tiles on a 2x2. - name: quad diff --git a/deploy/install.sh b/deploy/install.sh index 6cdaf24..f06f039 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -19,6 +19,10 @@ set -euo pipefail KIOSK_USER="${1:-kiosk}" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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 echo "error: run as root (sudo)" >&2 @@ -33,6 +37,16 @@ fi echo ">> installing binary to /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" install -d /etc/rtsp-streamer/sway install -m 0644 "${REPO_DIR}/deploy/sway/config" /etc/rtsp-streamer/sway/config @@ -73,7 +87,7 @@ cat < 0, makes the daemon periodically re-sync the // active layout from its linked UniFi Protect live view (ProtectView). // Requires controller credentials available to the daemon. 0 = off. - ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty"` + ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty" json:"view_refresh_seconds,omitempty"` // 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 @@ -51,24 +55,24 @@ type Config struct { // so it stays legible on both bright (day) and dark (night) scenes. type Clock struct { // 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 // 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). // 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, // top-right, top-left, bottom-center, top-center. The *-center positions // center the overlay horizontally and ignore Margin on that axis. - Corner string `yaml:"corner,omitempty"` + Corner string `yaml:"corner,omitempty" json:"corner,omitempty"` // 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 int `yaml:"width,omitempty"` - Height int `yaml:"height,omitempty"` + Width int `yaml:"width,omitempty" json:"width,omitempty"` + Height int `yaml:"height,omitempty" json:"height,omitempty"` // 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. // @@ -77,23 +81,28 @@ type Clock struct { // 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"` + BackgroundOpacity float64 `yaml:"background_opacity,omitempty" json:"background_opacity,omitempty"` } // Controller holds UniFi Protect connection details. type Controller struct { - Host string `yaml:"host"` // hostname or IP of the UniFi OS console - Username string `yaml:"username"` // local Protect user with camera access + Host string `yaml:"host" json:"host"` // hostname or IP of the UniFi OS console + Username string `yaml:"username" json:"username"` // local Protect user with camera access // Password is read here only if PasswordEnv is empty. Prefer PasswordEnv // 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 // self-signed cert by default, so this is false unless you install a // 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 int `yaml:"rtsp_port,omitempty"` + RTSPPort int `yaml:"rtsp_port,omitempty" json:"rtsp_port,omitempty"` } // ResolvePassword returns the effective password, preferring the env var. @@ -108,54 +117,54 @@ func (c Controller) ResolvePassword() string { // Display pins the render resolution. type Display struct { - Width int `yaml:"width,omitempty"` - Height int `yaml:"height,omitempty"` + Width int `yaml:"width,omitempty" json:"width,omitempty"` + Height int `yaml:"height,omitempty" json:"height,omitempty"` } // Player is shared mpv configuration. type Player struct { // HWDec selects mpv's hardware decoder (e.g. "auto-safe", "v4l2m2m", // "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 // feeds. Empty disables it. - Profile string `yaml:"profile"` + Profile string `yaml:"profile" json:"profile"` // 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 // 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 // feeds is unwatchable with sound, and skipping the audio decoder saves // 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 // 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 // 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 // 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 // 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. type Camera struct { // ID is the UniFi Protect camera id, when discovered. Blank for manual // 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 string `yaml:"name"` + Name string `yaml:"name" json:"name"` // RTSP is a single fully-resolved stream URL. Kept for backward // 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 // 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 bool `yaml:"disabled,omitempty"` + Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` } // Qualities in preference order, high to low. @@ -208,36 +217,42 @@ func (c Camera) AvailableQualities() []string { // more than this on a Pi 4 is impractical even with substreams. 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 // 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) // is still accepted and is transparently upgraded to tiles. 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 // sized in these cells. - Grid string `yaml:"grid"` + Grid string `yaml:"grid" json:"grid"` // 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 // 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 // layout mirrors. `views import` sets it; the daemon re-syncs it on a timer // 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. type Tile struct { - Camera string `yaml:"camera"` - Col int `yaml:"col"` - Row int `yaml:"row"` - ColSpan int `yaml:"colspan,omitempty"` // defaults to 1 - RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1 + Camera string `yaml:"camera" json:"camera"` + Col int `yaml:"col" json:"col"` + Row int `yaml:"row" json:"row"` + ColSpan int `yaml:"colspan,omitempty" json:"colspan,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"| // "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. diff --git a/internal/tui/gridedit.go b/internal/tui/gridedit.go index cb49665..53ef8f1 100644 --- a/internal/tui/gridedit.go +++ b/internal/tui/gridedit.go @@ -7,9 +7,9 @@ import ( "github.com/lwoodard/rtsp-streamer/internal/config" ) -// maxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity; -// the number of *tiles* (cameras) is separately capped at config.MaxTiles. -const maxGridDim = 8 +// maxGridDim caps each base-grid axis; see config.MaxGridDim, which the CLI +// and the opentui configurator share. +const maxGridDim = config.MaxGridDim // 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). diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a6dfa8b..6f5235b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,7 +1,15 @@ -// Package tui is the interactive Bubble Tea configurator. It is meant to be -// run over SSH on the headless Pi: 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. +// Package tui is the legacy Bubble Tea configurator, reachable as +// `rtsp-streamer tui --legacy`. The default configurator is the opentui one in +// tui/ (see tui/README.md); this one is kept as a fallback because it is part of +// 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 import ( diff --git a/tui/.gitignore b/tui/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/tui/.gitignore @@ -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 diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..a928efe --- /dev/null +++ b/tui/README.md @@ -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). diff --git a/tui/bun.lock b/tui/bun.lock new file mode 100644 index 0000000..399f7d6 --- /dev/null +++ b/tui/bun.lock @@ -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=="], + } +} diff --git a/tui/package.json b/tui/package.json new file mode 100644 index 0000000..2727629 --- /dev/null +++ b/tui/package.json @@ -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" + } +} diff --git a/tui/scripts/preview.ts b/tui/scripts/preview.ts new file mode 100644 index 0000000..3bbc669 --- /dev/null +++ b/tui/scripts/preview.ts @@ -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() +} diff --git a/tui/src/bridge.ts b/tui/src/bridge.ts new file mode 100644 index 0000000..60194d2 --- /dev/null +++ b/tui/src/bridge.ts @@ -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 { + 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(args: string[], stdin?: string): Promise { + 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 { + return runJSON(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 { + const payload = JSON.stringify({ + cameras: cfg.cameras, + layouts: cfg.layouts, + active_layout: cfg.active_layout, + }) + return runJSON(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 { + const args = ["discover", "--json"] + if (enable) args.push(`--enable-rtsp=${enable}`) + return runJSON(withConfig(args, opts)) +} diff --git a/tui/src/fixtures.ts b/tui/src/fixtures.ts new file mode 100644 index 0000000..d548cc6 --- /dev/null +++ b/tui/src/fixtures.ts @@ -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: {}, + }, + } +} diff --git a/tui/src/index.ts b/tui/src/index.ts new file mode 100644 index 0000000..16851f1 --- /dev/null +++ b/tui/src/index.ts @@ -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 { + 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((resolve) => { + renderer.on("destroy", () => resolve()) + }) + + return 0 +} + +process.exitCode = await main() diff --git a/tui/src/keys.ts b/tui/src/keys.ts new file mode 100644 index 0000000..c012d00 --- /dev/null +++ b/tui/src/keys.ts @@ -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 + } + } +} diff --git a/tui/src/state.test.ts b/tui/src/state.test.ts new file mode 100644 index 0000000..3875173 --- /dev/null +++ b/tui/src/state.test.ts @@ -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) + }) +}) diff --git a/tui/src/state.ts b/tui/src/state.ts new file mode 100644 index 0000000..fcc31b0 --- /dev/null +++ b/tui/src/state.ts @@ -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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/tui/src/theme.ts b/tui/src/theme.ts new file mode 100644 index 0000000..ced09eb --- /dev/null +++ b/tui/src/theme.ts @@ -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 diff --git a/tui/src/types.test.ts b/tui/src/types.test.ts new file mode 100644 index 0000000..7fafa5e --- /dev/null +++ b/tui/src/types.test.ts @@ -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([]) + }) +}) diff --git a/tui/src/types.ts b/tui/src/types.ts new file mode 100644 index 0000000..f1b6047 --- /dev/null +++ b/tui/src/types.ts @@ -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 + 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 + cameras: Camera[] + layouts: Layout[] + active_layout: string + view_refresh_seconds?: number + clock: Record +} + +/** 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 +} + +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 + } +} diff --git a/tui/src/ui.test.ts b/tui/src/ui.test.ts new file mode 100644 index 0000000..7c3c80b --- /dev/null +++ b/tui/src/ui.test.ts @@ -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 { + 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 + }) +}) diff --git a/tui/src/ui.ts b/tui/src/ui.ts new file mode 100644 index 0000000..fbf515e --- /dev/null +++ b/tui/src/ui.ts @@ -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("") + "…" +} diff --git a/tui/tsconfig.json b/tui/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tui/tsconfig.json @@ -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 + } +}