Files
RTSP-Streamer/internal/tui/tui.go
2026-07-01 18:21:14 -05:00

410 lines
10 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"
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
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()).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. When enable is non-empty (e.g. "high"), it also
// turns on RTSP in Protect for any camera that lacks an enabled channel.
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}
}
enabled := 0
if enable != "" {
names, _ := cl.EnableMissing(ctx, cams, enable)
enabled = len(names)
}
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.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 _, cam := range msg.cams {
ch := cam.BestEnabledChannel()
if ch == nil {
skipped++
continue
}
url := cl.StreamURL(ch.RTSPAlias)
if ex := m.cameraByID(cam.ID); ex != nil {
ex.Name, ex.RTSP = cam.Name, url
updated++
} else if ex := m.cfg.CameraByName(cam.Name); ex != nil {
ex.ID, ex.RTSP = cam.ID, url
updated++
} else {
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url})
added++
}
}
m.dirty = true
enabledNote := ""
if msg.enabled > 0 {
enabledNote = fmt.Sprintf(", enabled RTSP on %d", 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 (high)",
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…", false)
return m, m.discoverCmd("high")
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
}