Per-tile stream quality (high/low), fps cap, latency flags
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>
This commit is contained in:
@@ -96,6 +96,8 @@ func (m *model) updateLayoutEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.screen, m.cursor = screenCameraPicker, 0
|
||||
case "c":
|
||||
m.clearAt()
|
||||
case "q": // cycle this tile's stream quality (high/low/auto)
|
||||
m.cycleQuality()
|
||||
case "L": // grow wider (toward the right)
|
||||
m.resizeTile(1, 0)
|
||||
case "H": // shrink narrower (from the right)
|
||||
@@ -149,6 +151,36 @@ func (m *model) clearAt() {
|
||||
}
|
||||
}
|
||||
|
||||
// cycleQuality steps the tile under the cursor through auto → its camera's
|
||||
// available stream qualities (high/low/...). Small tiles → pick low; big tiles
|
||||
// → high, to keep the Pi's decode load down.
|
||||
func (m *model) cycleQuality() {
|
||||
idx := m.tileIndexAt(m.edCol, m.edRow)
|
||||
if idx < 0 {
|
||||
m.setStatus("no tile here — assign a camera first", true)
|
||||
return
|
||||
}
|
||||
t := &m.curLayout().Tiles[idx]
|
||||
opts := []string{""} // "" = auto (best available)
|
||||
if cam := m.cfg.CameraByName(t.Camera); cam != nil {
|
||||
opts = append(opts, cam.AvailableQualities()...)
|
||||
}
|
||||
cur := 0
|
||||
for i, q := range opts {
|
||||
if q == t.Quality {
|
||||
cur = i
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Quality = opts[(cur+1)%len(opts)]
|
||||
m.dirty = true
|
||||
label := t.Quality
|
||||
if label == "" {
|
||||
label = "auto"
|
||||
}
|
||||
m.setStatus("tile quality: "+label, false)
|
||||
}
|
||||
|
||||
// resizeTile grows/shrinks the tile under the cursor by the given span deltas,
|
||||
// keeping it in-bounds and non-overlapping.
|
||||
func (m *model) resizeTile(dCol, dRow int) {
|
||||
|
||||
@@ -7,6 +7,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -95,8 +96,8 @@ type savedMsg struct {
|
||||
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.
|
||||
// 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 {
|
||||
@@ -119,10 +120,25 @@ func (m *model) discoverCmd(enable string) tea.Cmd {
|
||||
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
|
||||
if enable != "" {
|
||||
names, _ := cl.EnableMissing(ctx, cams, enable)
|
||||
enabled = len(names)
|
||||
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}
|
||||
}
|
||||
@@ -161,28 +177,35 @@ func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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++
|
||||
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 RTSP on %d", msg.enabled)
|
||||
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
|
||||
@@ -229,7 +252,7 @@ var menuItems = []string{
|
||||
menuLayouts: "Layouts",
|
||||
menuSetActive: "Set active layout",
|
||||
menuDiscover: "Discover from UniFi Protect",
|
||||
menuDiscoverEnable: "Discover + enable RTSP (high)",
|
||||
menuDiscoverEnable: "Discover + enable RTSP (hi+lo)",
|
||||
menuSave: "Save",
|
||||
menuQuit: "Quit",
|
||||
}
|
||||
@@ -252,8 +275,8 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.setStatus("discovering…", false)
|
||||
return m, m.discoverCmd("")
|
||||
case menuDiscoverEnable:
|
||||
m.setStatus("discovering and enabling RTSP…", false)
|
||||
return m, m.discoverCmd("high")
|
||||
m.setStatus("discovering and enabling RTSP (high+low)…", false)
|
||||
return m, m.discoverCmd("high,low")
|
||||
case menuSave:
|
||||
return m, m.save()
|
||||
case menuQuit:
|
||||
|
||||
@@ -107,13 +107,27 @@ func (m *model) viewLayoutEdit() string {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(strings.Join([]string{
|
||||
hkey("click", "assign cell") + " " + hkey("drag a tile", "resize") + " (mouse)",
|
||||
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear"),
|
||||
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear") + " " + hkey("q", "quality"),
|
||||
"resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"),
|
||||
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
|
||||
}, "\n"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// qualityAbbrev shortens a quality name for the compact tile label.
|
||||
func qualityAbbrev(q string) string {
|
||||
switch q {
|
||||
case "high":
|
||||
return "hi"
|
||||
case "medium":
|
||||
return "med"
|
||||
case "low":
|
||||
return "lo"
|
||||
default:
|
||||
return q
|
||||
}
|
||||
}
|
||||
|
||||
// renderGrid draws the base grid with tiles, camera labels, continuation marks
|
||||
// for spanned cells, and a highlighted cursor cell — a live picture of the wall.
|
||||
func (m *model) renderGrid(cols, rows int) string {
|
||||
@@ -156,9 +170,17 @@ func (m *model) renderCell(l config.Layout, c, r, line int) string {
|
||||
plain = padTo(truncate(label, cellW), cellW)
|
||||
case isOrigin && line == 1:
|
||||
cs, rs := t.Span()
|
||||
info := ""
|
||||
if cs > 1 || rs > 1 {
|
||||
plain = padTo(fmt.Sprintf("%dx%d", cs, rs), cellW)
|
||||
info = fmt.Sprintf("%dx%d", cs, rs)
|
||||
}
|
||||
if t.Quality != "" {
|
||||
if info != "" {
|
||||
info += " "
|
||||
}
|
||||
info += qualityAbbrev(t.Quality)
|
||||
}
|
||||
plain = padTo(info, cellW)
|
||||
dim = true
|
||||
default: // continuation cell of a spanned tile
|
||||
plain = center("·", cellW)
|
||||
|
||||
Reference in New Issue
Block a user