Cameras carry multiple stream URLs (streams: high/medium/low); each tile picks one via Quality (auto/high/low). Small tiles can pull the low substream to cut Pi decode load. discover --enable-rtsp takes a comma list (high,low), enables each channel, records all enabled ones. TUI: q cycles a tile quality (shown in the tile); discover enables high+low. Player: --framedrop + low-delay demuxer flags; player.max_fps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
440 lines
11 KiB
Go
440 lines
11 KiB
Go
// 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
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
|
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
|
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
|
)
|
|
|
|
type screen int
|
|
|
|
const (
|
|
screenMenu screen = iota
|
|
screenCameras
|
|
screenLayouts
|
|
screenLayoutEdit
|
|
screenCameraPicker
|
|
screenSetActive
|
|
)
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Padding(0, 1)
|
|
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
|
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
|
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
|
// helpStyle adapts to light/dark terminals so it stays legible on both.
|
|
helpStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.AdaptiveColor{Light: "238", Dark: "251"}).MarginTop(1)
|
|
// keyStyle accents the key names inside help lines.
|
|
keyStyle = lipgloss.NewStyle().Bold(true).
|
|
Foreground(lipgloss.AdaptiveColor{Light: "26", Dark: "81"})
|
|
// usedStyle marks cameras already placed in the current layout.
|
|
usedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
|
// cursorCellStyle highlights the selected grid cell in the layout editor.
|
|
cursorCellStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("205"))
|
|
)
|
|
|
|
// hkey formats "<key> <desc>" with the key accented, for help lines.
|
|
func hkey(key, desc string) string {
|
|
return keyStyle.Render(key) + " " + desc
|
|
}
|
|
|
|
type model struct {
|
|
cfgPath string
|
|
cfg *config.Config
|
|
dirty bool
|
|
|
|
screen screen
|
|
cursor int
|
|
|
|
editLayout int // index into cfg.Layouts for edit/picker screens
|
|
edCol, edRow int // grid-editor cursor position
|
|
|
|
// Mouse drag state for tmux-style tile resizing in the grid editor.
|
|
dragging bool
|
|
dragTile int
|
|
dragMoved bool
|
|
|
|
status string
|
|
isError bool
|
|
}
|
|
|
|
// Run loads the config and starts the TUI event loop.
|
|
func Run(cfgPath string) error {
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m := &model{cfgPath: cfgPath, cfg: cfg, screen: screenMenu}
|
|
_, err = tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
|
|
return err
|
|
}
|
|
|
|
func (m *model) Init() tea.Cmd { return nil }
|
|
|
|
// ---- messages ----
|
|
|
|
type discoverMsg struct {
|
|
cams []protect.Camera
|
|
enabled int // count of cameras we turned RTSP on for
|
|
err error
|
|
}
|
|
|
|
type savedMsg struct {
|
|
err error
|
|
reloaded bool
|
|
}
|
|
|
|
// discoverCmd fetches cameras. `enable` is a comma-separated list of qualities
|
|
// (e.g. "high,low") to ensure RTSP-enabled in Protect and record per camera.
|
|
func (m *model) discoverCmd(enable string) tea.Cmd {
|
|
cfg := m.cfg
|
|
return func() tea.Msg {
|
|
if cfg.Controller.Host == "" {
|
|
return discoverMsg{err: fmt.Errorf("controller.host is not set")}
|
|
}
|
|
pw := cfg.Controller.ResolvePassword()
|
|
if pw == "" {
|
|
return discoverMsg{err: fmt.Errorf("no controller password available")}
|
|
}
|
|
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
|
if err != nil {
|
|
return discoverMsg{err: err}
|
|
}
|
|
ctx := context.Background()
|
|
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
|
|
return discoverMsg{err: err}
|
|
}
|
|
cams, err := cl.Cameras(ctx)
|
|
if err != nil {
|
|
return discoverMsg{err: err}
|
|
}
|
|
var want []string
|
|
for _, q := range strings.Split(enable, ",") {
|
|
if q = strings.TrimSpace(strings.ToLower(q)); q != "" {
|
|
want = append(want, q)
|
|
}
|
|
}
|
|
enabled := 0
|
|
for i := range cams {
|
|
cam := &cams[i]
|
|
for _, q := range want {
|
|
target := cam.ChannelByPreference(q)
|
|
if target == nil || (target.RTSPEnabled && target.RTSPAlias != "") {
|
|
continue
|
|
}
|
|
if alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID); err == nil {
|
|
target.RTSPEnabled, target.RTSPAlias = true, alias
|
|
enabled++
|
|
}
|
|
}
|
|
}
|
|
return discoverMsg{cams: cams, enabled: enabled}
|
|
}
|
|
}
|
|
|
|
// ---- update ----
|
|
|
|
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case discoverMsg:
|
|
return m.handleDiscover(msg)
|
|
case savedMsg:
|
|
if msg.err != nil {
|
|
m.setStatus("save failed: "+msg.err.Error(), true)
|
|
return m, nil
|
|
}
|
|
m.dirty = false
|
|
if msg.reloaded {
|
|
m.setStatus("saved and reloaded the running daemon", false)
|
|
} else {
|
|
m.setStatus("saved to "+m.cfgPath, false)
|
|
}
|
|
return m, nil
|
|
case tea.MouseMsg:
|
|
return m.handleMouse(msg)
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) {
|
|
if msg.err != nil {
|
|
m.setStatus("discover failed: "+msg.err.Error(), true)
|
|
return m, nil
|
|
}
|
|
cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS)
|
|
added, updated, skipped := 0, 0, 0
|
|
for i := range msg.cams {
|
|
cam := &msg.cams[i]
|
|
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 {
|
|
skipped++
|
|
continue
|
|
}
|
|
entry := m.cameraByID(cam.ID)
|
|
if entry == nil {
|
|
entry = m.cfg.CameraByName(cam.Name)
|
|
}
|
|
if entry == nil {
|
|
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{})
|
|
entry = &m.cfg.Cameras[len(m.cfg.Cameras)-1]
|
|
added++
|
|
} else {
|
|
updated++
|
|
}
|
|
entry.ID, entry.Name, entry.Streams, entry.RTSP = cam.ID, cam.Name, streams, ""
|
|
}
|
|
m.dirty = true
|
|
enabledNote := ""
|
|
if msg.enabled > 0 {
|
|
enabledNote = fmt.Sprintf(", enabled %d channels", msg.enabled)
|
|
}
|
|
m.setStatus(fmt.Sprintf("discovered %d (%d new, %d updated, %d without RTSP%s)", len(msg.cams), added, updated, skipped, enabledNote), false)
|
|
m.screen = screenCameras
|
|
m.cursor = 0
|
|
return m, nil
|
|
}
|
|
|
|
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
switch m.screen {
|
|
case screenMenu:
|
|
return m.updateMenu(msg)
|
|
case screenCameras:
|
|
return m.updateCameras(msg)
|
|
case screenLayouts:
|
|
return m.updateLayouts(msg)
|
|
case screenLayoutEdit:
|
|
return m.updateLayoutEdit(msg)
|
|
case screenCameraPicker:
|
|
return m.updateCameraPicker(msg)
|
|
case screenSetActive:
|
|
return m.updateSetActive(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// menu -------------------------------------------------------------
|
|
|
|
const (
|
|
menuCameras = iota
|
|
menuLayouts
|
|
menuSetActive
|
|
menuDiscover
|
|
menuDiscoverEnable
|
|
menuSave
|
|
menuQuit
|
|
)
|
|
|
|
var menuItems = []string{
|
|
menuCameras: "Cameras",
|
|
menuLayouts: "Layouts",
|
|
menuSetActive: "Set active layout",
|
|
menuDiscover: "Discover from UniFi Protect",
|
|
menuDiscoverEnable: "Discover + enable RTSP (hi+lo)",
|
|
menuSave: "Save",
|
|
menuQuit: "Quit",
|
|
}
|
|
|
|
func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
m.moveCursor(-1, len(menuItems))
|
|
case "down", "j":
|
|
m.moveCursor(1, len(menuItems))
|
|
case "enter", "l", " ":
|
|
switch m.cursor {
|
|
case menuCameras:
|
|
m.screen, m.cursor = screenCameras, 0
|
|
case menuLayouts:
|
|
m.screen, m.cursor = screenLayouts, 0
|
|
case menuSetActive:
|
|
m.screen, m.cursor = screenSetActive, 0
|
|
case menuDiscover:
|
|
m.setStatus("discovering…", false)
|
|
return m, m.discoverCmd("")
|
|
case menuDiscoverEnable:
|
|
m.setStatus("discovering and enabling RTSP (high+low)…", false)
|
|
return m, m.discoverCmd("high,low")
|
|
case menuSave:
|
|
return m, m.save()
|
|
case menuQuit:
|
|
return m, m.quit()
|
|
}
|
|
case "q":
|
|
return m, m.quit()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// cameras ----------------------------------------------------------
|
|
|
|
func (m *model) updateCameras(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
n := len(m.cfg.Cameras)
|
|
switch msg.String() {
|
|
case "esc", "backspace", "h":
|
|
m.screen, m.cursor = screenMenu, 0
|
|
case "up", "k":
|
|
m.moveCursor(-1, n)
|
|
case "down", "j":
|
|
m.moveCursor(1, n)
|
|
case "d":
|
|
if n > 0 {
|
|
m.cfg.Cameras[m.cursor].Disabled = !m.cfg.Cameras[m.cursor].Disabled
|
|
m.dirty = true
|
|
}
|
|
case "x":
|
|
if n > 0 {
|
|
m.cfg.Cameras = append(m.cfg.Cameras[:m.cursor], m.cfg.Cameras[m.cursor+1:]...)
|
|
if m.cursor >= len(m.cfg.Cameras) && m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
m.dirty = true
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// layouts ----------------------------------------------------------
|
|
|
|
func (m *model) updateLayouts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
n := len(m.cfg.Layouts)
|
|
switch msg.String() {
|
|
case "esc", "backspace", "h":
|
|
m.screen, m.cursor = screenMenu, 0
|
|
case "up", "k":
|
|
m.moveCursor(-1, n)
|
|
case "down", "j":
|
|
m.moveCursor(1, n)
|
|
case "enter", "l":
|
|
if n > 0 {
|
|
m.editLayout = m.cursor
|
|
m.migrateToTiles(m.editLayout)
|
|
m.edCol, m.edRow = 0, 0
|
|
m.screen, m.cursor = screenLayoutEdit, 0
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// (grid-editor update logic lives in gridedit.go)
|
|
|
|
// camera picker ----------------------------------------------------
|
|
|
|
// pickerOptions is "(empty)" followed by enabled camera names.
|
|
func (m *model) pickerOptions() []string {
|
|
opts := []string{"(empty)"}
|
|
for _, c := range m.cfg.Cameras {
|
|
if c.Disabled {
|
|
continue
|
|
}
|
|
opts = append(opts, c.Name)
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func (m *model) updateCameraPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
opts := m.pickerOptions()
|
|
switch msg.String() {
|
|
case "esc", "backspace", "h":
|
|
m.screen, m.cursor = screenLayoutEdit, 0
|
|
case "up", "k":
|
|
m.moveCursor(-1, len(opts))
|
|
case "down", "j":
|
|
m.moveCursor(1, len(opts))
|
|
case "enter", "l":
|
|
choice := ""
|
|
if m.cursor > 0 {
|
|
choice = opts[m.cursor]
|
|
}
|
|
m.assignCamera(choice)
|
|
m.screen, m.cursor = screenLayoutEdit, 0
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// set active -------------------------------------------------------
|
|
|
|
func (m *model) updateSetActive(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
n := len(m.cfg.Layouts)
|
|
switch msg.String() {
|
|
case "esc", "backspace", "h":
|
|
m.screen, m.cursor = screenMenu, 0
|
|
case "up", "k":
|
|
m.moveCursor(-1, n)
|
|
case "down", "j":
|
|
m.moveCursor(1, n)
|
|
case "enter", "l":
|
|
if n > 0 {
|
|
m.cfg.ActiveLayout = m.cfg.Layouts[m.cursor].Name
|
|
m.dirty = true
|
|
m.setStatus("active layout set to "+m.cfg.ActiveLayout, false)
|
|
m.screen, m.cursor = screenMenu, 0
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// helpers ----------------------------------------------------------
|
|
|
|
func (m *model) moveCursor(delta, n int) {
|
|
if n == 0 {
|
|
m.cursor = 0
|
|
return
|
|
}
|
|
m.cursor = (m.cursor + delta + n) % n
|
|
}
|
|
|
|
func (m *model) cameraByID(id string) *config.Camera {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
for i := range m.cfg.Cameras {
|
|
if m.cfg.Cameras[i].ID == id {
|
|
return &m.cfg.Cameras[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *model) setStatus(s string, isErr bool) {
|
|
m.status, m.isError = s, isErr
|
|
}
|
|
|
|
func (m *model) save() tea.Cmd {
|
|
cfgPath, cfg := m.cfgPath, m.cfg
|
|
return func() tea.Msg {
|
|
if err := config.Save(cfgPath, cfg); err != nil {
|
|
return savedMsg{err: err}
|
|
}
|
|
// Best-effort: tell a running daemon to reload.
|
|
_, err := ipc.Send(ipc.Request{Cmd: "reload"})
|
|
return savedMsg{reloaded: err == nil}
|
|
}
|
|
}
|
|
|
|
func (m *model) quit() tea.Cmd {
|
|
return tea.Quit
|
|
}
|