Rebuild the interactive configurator on opentui behind a JSON bridge
The TUI is now a TypeScript/opentui app in tui/ rather than Bubble Tea.
opentui is a Zig core with TypeScript bindings and no Go bindings, so this half
of the tool can't live in the Go binary; it compiles with Bun into a sibling
executable (rtsp-streamer-tui) that `rtsp-streamer tui` execs.
Everything that isn't presentation stays in Go, reached over three JSON
commands. The configurator holds no credentials and never writes the config
itself:
config export the config, plus limits like max_tiles
config apply (stdin) merge cameras/layouts/active_layout, validate, save
atomically, reload the daemon
discover --json Protect discovery, writing nothing
Two properties of that split are deliberate:
- The controller password never crosses the bridge. It's json:"-" on the way
out, and apply only merges the three keys the TUI edits, so it can't be
clobbered on the way back in either.
- apply re-reads the file before merging, so an editor left open for an hour can
no longer overwrite a `views import`, a `layout set`, or a hand edit made in
the meantime.
Discovery is previewable as a result: `discover --json` writes nothing, the
merge happens in the TUI, and nothing reaches disk until you save. Only
--enable-rtsp has a side effect, and it's on the controller.
Config structs gain json tags mirroring their yaml ones so the config
round-trips through the bridge under the same key names it has on disk, and
maxGridDim moves to config.MaxGridDim so the CLI and both configurators enforce
one ceiling. The write path is byte-for-byte identical to `layout set`, checked
against a copy of a live config.
Visible change: the grid editor draws real bordered boxes, so a spanning tile is
one box instead of an origin cell plus "·" continuation marks, and the
header-offset arithmetic in mouse.go is gone — the framework hit-tests list
rows. Keybindings, the lipgloss palette and the screen flow are carried over
unchanged; S now saves from anywhere.
The Bubble Tea version stays as `tui --legacy`. It's compiled into the Go binary
and needs no Bun, and on a headless Pi the TUI is the only config UI there is,
so a fallback is worth its weight. The cost of the new one is size: ~120 MB
against ~13 MB, since Bun embeds its runtime and opentui's native library.
Tests: 67 bun tests drive the real (in-memory) opentui renderer, including mouse
click and drag, plus tsc --noEmit. `make test-tui` runs both, and
scripts/preview.ts dumps every screen as text without needing a terminal.
Three bugs found during the port are documented in tui/README.md, since none are
apparent from the code: overlapping cell borders render as ┌ where a lattice
needs ┬; a drag dies after the first resize if the tree is rebuilt, because the
renderer captures the press-target renderable; and a rebuilt box has no computed
layout until the next frame, so its screenX reads 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,36 +14,40 @@ import (
|
||||
)
|
||||
|
||||
// Config is the root document persisted to disk.
|
||||
//
|
||||
// The json tags mirror the yaml ones so the config can round-trip through the
|
||||
// `config export` / `config apply` bridge (used by the opentui configurator in
|
||||
// tui/) with exactly the same key names it has on disk.
|
||||
type Config struct {
|
||||
// Controller describes how to reach the UniFi Protect controller for
|
||||
// camera discovery. Optional if you only use manually-added cameras.
|
||||
Controller Controller `yaml:"controller"`
|
||||
Controller Controller `yaml:"controller" json:"controller"`
|
||||
|
||||
// Display pins the output resolution used for grid geometry math. When
|
||||
// zero, the daemon asks the compositor for the connected output's mode.
|
||||
Display Display `yaml:"display"`
|
||||
Display Display `yaml:"display" json:"display"`
|
||||
|
||||
// Player holds mpv tuning shared by every stream.
|
||||
Player Player `yaml:"player"`
|
||||
Player Player `yaml:"player" json:"player"`
|
||||
|
||||
// Cameras is the discovered/known camera catalog. Populated by
|
||||
// `rtsp-streamer discover` or edited by hand. Layouts reference cameras
|
||||
// by Name.
|
||||
Cameras []Camera `yaml:"cameras"`
|
||||
Cameras []Camera `yaml:"cameras" json:"cameras"`
|
||||
|
||||
// Layouts are named preset grids.
|
||||
Layouts []Layout `yaml:"layouts"`
|
||||
Layouts []Layout `yaml:"layouts" json:"layouts"`
|
||||
|
||||
// ActiveLayout is the name of the layout the daemon renders.
|
||||
ActiveLayout string `yaml:"active_layout"`
|
||||
ActiveLayout string `yaml:"active_layout" json:"active_layout"`
|
||||
|
||||
// ViewRefreshSeconds, when > 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user