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:
16
README.md
16
README.md
@@ -46,7 +46,18 @@ On the display device:
|
|||||||
Debian / Raspberry Pi OS: `sudo apt install sway mpv foot`
|
Debian / Raspberry Pi OS: `sudo apt install sway mpv foot`
|
||||||
Arch: `sudo pacman -S sway mpv foot`
|
Arch: `sudo pacman -S sway mpv foot`
|
||||||
|
|
||||||
To build: Go 1.23+ (only on your dev machine; the Pi just needs the binary).
|
To build: **Go 1.23+**. You can build on your dev machine and copy the binary,
|
||||||
|
or build on the Pi itself.
|
||||||
|
|
||||||
|
> ⚠️ 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`:
|
||||||
|
> ```sh
|
||||||
|
> curl -sSLO https://go.dev/dl/go1.23.4.linux-arm64.tar.gz # match uname -m
|
||||||
|
> rm -rf ~/.local/go && mkdir -p ~/.local && tar -C ~/.local -xzf go1.23.4.linux-arm64.tar.gz
|
||||||
|
> echo 'export PATH=$HOME/.local/go/bin:$PATH' >> ~/.profile && export PATH=$HOME/.local/go/bin:$PATH
|
||||||
|
> go version # must show 1.23+, not 1.19
|
||||||
|
> ```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -55,6 +66,9 @@ make build # native binary -> bin/rtsp-streamer
|
|||||||
make pi # Raspberry Pi 4, 64-bit OS (arm64) -> bin/rtsp-streamer-arm64
|
make pi # Raspberry Pi 4, 64-bit OS (arm64) -> bin/rtsp-streamer-arm64
|
||||||
make pi32 # 32-bit Pi OS (armv7) -> bin/rtsp-streamer-armv7
|
make pi32 # 32-bit Pi OS (armv7) -> bin/rtsp-streamer-armv7
|
||||||
make test
|
make test
|
||||||
|
make install # build native + copy to /usr/local/bin (auto-sudo)
|
||||||
|
make deploy # install + restart the kiosk session
|
||||||
|
rtsp-streamer version # prints the baked-in git commit / build date
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
@@ -56,7 +57,6 @@ func daemonCmd() *cobra.Command {
|
|||||||
|
|
||||||
// discoverCmd queries UniFi Protect and merges cameras into the config.
|
// discoverCmd queries UniFi Protect and merges cameras into the config.
|
||||||
func discoverCmd() *cobra.Command {
|
func discoverCmd() *cobra.Command {
|
||||||
var lowRes bool
|
|
||||||
var enableRTSP string
|
var enableRTSP string
|
||||||
c := &cobra.Command{
|
c := &cobra.Command{
|
||||||
Use: "discover",
|
Use: "discover",
|
||||||
@@ -86,39 +86,57 @@ func discoverCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if enableRTSP != "" {
|
// Qualities to ensure RTSP-enabled, e.g. --enable-rtsp=high,low
|
||||||
enabled, failed := cl.EnableMissing(ctx, cams, enableRTSP)
|
var wantEnable []string
|
||||||
for _, name := range enabled {
|
for _, q := range strings.Split(enableRTSP, ",") {
|
||||||
fmt.Printf(" + enabled RTSP (%s) on %s\n", enableRTSP, name)
|
if q = strings.TrimSpace(strings.ToLower(q)); q != "" {
|
||||||
}
|
wantEnable = append(wantEnable, q)
|
||||||
for name, ferr := range failed {
|
|
||||||
fmt.Fprintf(os.Stderr, " ! could not enable RTSP on %s: %v\n", name, ferr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
added, updated, skipped := 0, 0, 0
|
added, updated, skipped := 0, 0, 0
|
||||||
for _, cam := range cams {
|
for i := range cams {
|
||||||
ch := cam.BestEnabledChannel()
|
cam := &cams[i]
|
||||||
if lowRes {
|
for _, q := range wantEnable {
|
||||||
ch = cam.LowestEnabledChannel()
|
target := cam.ChannelByPreference(q)
|
||||||
|
if target == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !target.RTSPEnabled || target.RTSPAlias == "" {
|
||||||
|
alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " ! %s: enable %q failed: %v\n", cam.Name, q, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target.RTSPEnabled, target.RTSPAlias = true, alias
|
||||||
|
fmt.Printf(" + enabled %s on %s\n", q, cam.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ch == nil {
|
// Record every enabled channel as a selectable quality.
|
||||||
fmt.Fprintf(os.Stderr, " ! %s: no RTSP-enabled channel (enable RTSP in Protect)\n", cam.Name)
|
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 {
|
||||||
|
fmt.Fprintf(os.Stderr, " ! %s: no RTSP-enabled channel (try --enable-rtsp=high,low)\n", cam.Name)
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
url := cl.StreamURL(ch.RTSPAlias)
|
entry := findByID(cfg, cam.ID)
|
||||||
if existing := findByID(cfg, cam.ID); existing != nil {
|
if entry == nil {
|
||||||
existing.Name, existing.RTSP = cam.Name, url
|
entry = cfg.CameraByName(cam.Name)
|
||||||
updated++
|
|
||||||
} else if existing := cfg.CameraByName(cam.Name); existing != nil {
|
|
||||||
existing.ID, existing.RTSP = cam.ID, url
|
|
||||||
updated++
|
|
||||||
} else {
|
|
||||||
cfg.Cameras = append(cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url})
|
|
||||||
added++
|
|
||||||
}
|
}
|
||||||
fmt.Printf(" ✓ %-24s %dx%d\n", cam.Name, ch.Width, ch.Height)
|
if entry == nil {
|
||||||
|
cfg.Cameras = append(cfg.Cameras, config.Camera{})
|
||||||
|
entry = &cfg.Cameras[len(cfg.Cameras)-1]
|
||||||
|
added++
|
||||||
|
} else {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
entry.ID, entry.Name, entry.Streams, entry.RTSP = cam.ID, cam.Name, streams, ""
|
||||||
|
fmt.Printf(" ✓ %-24s [%s]\n", cam.Name, strings.Join(availQual(streams), ","))
|
||||||
}
|
}
|
||||||
if err := config.Save(cfgPath, cfg); err != nil {
|
if err := config.Save(cfgPath, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -128,11 +146,21 @@ func discoverCmd() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
c.Flags().BoolVar(&lowRes, "low-res", false, "prefer the lowest-resolution substream (good for dense grids)")
|
c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "comma-separated channels to enable in Protect and record, e.g. high,low")
|
||||||
c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "enable RTSP in Protect on this channel for cameras that lack it: high|medium|low")
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// availQual lists the qualities present in a stream map, high to low.
|
||||||
|
func availQual(streams map[string]string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, q := range config.Qualities {
|
||||||
|
if streams[q] != "" {
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func findByID(cfg *config.Config, id string) *config.Camera {
|
func findByID(cfg *config.Config, id string) *config.Camera {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -16,23 +16,32 @@ display:
|
|||||||
height: 1080
|
height: 1080
|
||||||
|
|
||||||
player:
|
player:
|
||||||
hwdec: auto-safe # v4l2m2m/drm on the Pi 4; "no" to force software
|
hwdec: v4l2m2m-copy # Pi 4 hardware H.264 decoder. "auto-safe" often
|
||||||
|
# won't engage it; "no" forces software.
|
||||||
profile: low-latency
|
profile: low-latency
|
||||||
|
max_fps: 0 # cap rendered fps (0 = uncapped); trims render load
|
||||||
restart_backoff_seconds: 3
|
restart_backoff_seconds: 3
|
||||||
extra_args: [] # e.g. ["--vf=fps=15"] to cap decode load
|
extra_args: []
|
||||||
|
|
||||||
# Camera catalog. Normally populated by `rtsp-streamer discover`; shown here
|
# Camera catalog. Normally populated by `rtsp-streamer discover`; shown here
|
||||||
# filled in by hand for illustration. Layouts reference cameras by `name`.
|
# by hand for illustration. Each camera can carry multiple stream qualities
|
||||||
|
# (high/medium/low) so a tile can choose which to pull. Layouts reference
|
||||||
|
# cameras by `name`. (A single `rtsp:` URL instead of `streams:` also works.)
|
||||||
cameras:
|
cameras:
|
||||||
- id: 60a1b2c3d4e5f6 # UniFi Protect camera id (blank for manual entries)
|
- id: 60a1b2c3d4e5f6 # UniFi Protect camera id (blank for manual entries)
|
||||||
name: Front Door
|
name: Front Door
|
||||||
rtsp: rtsps://192.168.1.1:7441/aBcD1234?enableSrtp
|
streams:
|
||||||
|
high: rtsps://192.168.1.1:7441/aBcD1234?enableSrtp
|
||||||
|
low: rtsps://192.168.1.1:7441/aBcD5678?enableSrtp
|
||||||
- name: Driveway
|
- name: Driveway
|
||||||
rtsp: rtsps://192.168.1.1:7441/eFgH5678?enableSrtp
|
streams:
|
||||||
|
high: rtsps://192.168.1.1:7441/eFgH1234?enableSrtp
|
||||||
|
low: rtsps://192.168.1.1:7441/eFgH5678?enableSrtp
|
||||||
- name: Back Yard
|
- name: Back Yard
|
||||||
rtsp: rtsps://192.168.1.1:7441/iJkL9012?enableSrtp
|
streams:
|
||||||
|
low: rtsps://192.168.1.1:7441/iJkL9012?enableSrtp
|
||||||
- name: Garage
|
- name: Garage
|
||||||
rtsp: rtsps://192.168.1.1:7441/mNoP3456?enableSrtp
|
rtsp: rtsps://192.168.1.1:7441/mNoP3456?enableSrtp # legacy single-URL form
|
||||||
|
|
||||||
# Layouts place cameras on a base grid (COLSxROWS). Cameras are "tiles" that
|
# 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
|
# may span multiple cells, so you can mix a big main view with small side
|
||||||
@@ -48,14 +57,15 @@ layouts:
|
|||||||
- {camera: Back Yard, col: 0, row: 1}
|
- {camera: Back Yard, col: 0, row: 1}
|
||||||
- {camera: Garage, col: 1, row: 1}
|
- {camera: Garage, col: 1, row: 1}
|
||||||
|
|
||||||
# Security-wall style: one big 3x3 main view + a right column of three.
|
# Security-wall style: one big 3x3 main view (high quality) + a right column
|
||||||
|
# of three small tiles on the low substream to keep decode load down.
|
||||||
- name: main-plus
|
- name: main-plus
|
||||||
grid: 4x3
|
grid: 4x3
|
||||||
tiles:
|
tiles:
|
||||||
- {camera: Front Door, col: 0, row: 0, colspan: 3, rowspan: 3}
|
- {camera: Front Door, col: 0, row: 0, colspan: 3, rowspan: 3, quality: high}
|
||||||
- {camera: Driveway, col: 3, row: 0}
|
- {camera: Driveway, col: 3, row: 0, quality: low}
|
||||||
- {camera: Back Yard, col: 3, row: 1}
|
- {camera: Back Yard, col: 3, row: 1, quality: low}
|
||||||
- {camera: Garage, col: 3, row: 2}
|
- {camera: Garage, col: 3, row: 2, quality: low}
|
||||||
|
|
||||||
- name: front-focus
|
- name: front-focus
|
||||||
grid: 1x1
|
grid: 1x1
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ type Player struct {
|
|||||||
Profile string `yaml:"profile"`
|
Profile string `yaml:"profile"`
|
||||||
// ExtraArgs are appended verbatim to every mpv invocation.
|
// ExtraArgs are appended verbatim to every mpv invocation.
|
||||||
ExtraArgs []string `yaml:"extra_args,omitempty"`
|
ExtraArgs []string `yaml:"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"`
|
||||||
// RestartBackoffSeconds is how long to wait before relaunching a stream
|
// RestartBackoffSeconds is how long to wait before relaunching a stream
|
||||||
// that exited or stalled.
|
// that exited or stalled.
|
||||||
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"`
|
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"`
|
||||||
@@ -92,12 +95,62 @@ type Camera struct {
|
|||||||
ID string `yaml:"id,omitempty"`
|
ID string `yaml:"id,omitempty"`
|
||||||
// Name is the human label and the key layouts reference. Must be unique.
|
// Name is the human label and the key layouts reference. Must be unique.
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
// RTSP is the fully-resolved stream URL.
|
// RTSP is a single fully-resolved stream URL. Kept for backward
|
||||||
RTSP string `yaml:"rtsp"`
|
// compatibility and manual entries; Streams takes precedence when present.
|
||||||
|
RTSP string `yaml:"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"`
|
||||||
// Disabled hides the camera from selection without deleting it.
|
// Disabled hides the camera from selection without deleting it.
|
||||||
Disabled bool `yaml:"disabled,omitempty"`
|
Disabled bool `yaml:"disabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Qualities in preference order, high to low.
|
||||||
|
var Qualities = []string{"high", "medium", "low"}
|
||||||
|
|
||||||
|
// StreamURL returns the URL for the requested quality, falling back sensibly:
|
||||||
|
// the exact quality, then any lower quality, then any stream at all, then the
|
||||||
|
// legacy single RTSP field.
|
||||||
|
func (c Camera) StreamURL(quality string) string {
|
||||||
|
if len(c.Streams) > 0 {
|
||||||
|
if quality != "" {
|
||||||
|
if u := c.Streams[quality]; u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back down the preference list from the requested quality.
|
||||||
|
start := 0
|
||||||
|
for i, q := range Qualities {
|
||||||
|
if q == quality {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, q := range Qualities[start:] {
|
||||||
|
if u := c.Streams[q]; u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, q := range Qualities {
|
||||||
|
if u := c.Streams[q]; u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.RTSP
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableQualities lists the qualities this camera actually has, high to low.
|
||||||
|
func (c Camera) AvailableQualities() []string {
|
||||||
|
var out []string
|
||||||
|
for _, q := range Qualities {
|
||||||
|
if c.Streams[q] != "" {
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// MaxTiles caps how many simultaneous streams a layout may show. Decoding
|
// MaxTiles caps how many simultaneous streams a layout may show. Decoding
|
||||||
// more than this on a Pi 4 is impractical even with substreams.
|
// more than this on a Pi 4 is impractical even with substreams.
|
||||||
const MaxTiles = 16
|
const MaxTiles = 16
|
||||||
@@ -125,6 +178,9 @@ type Tile struct {
|
|||||||
Row int `yaml:"row"`
|
Row int `yaml:"row"`
|
||||||
ColSpan int `yaml:"colspan,omitempty"` // defaults to 1
|
ColSpan int `yaml:"colspan,omitempty"` // defaults to 1
|
||||||
RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1
|
RowSpan int `yaml:"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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Span returns the tile's spans with zero values normalized to 1.
|
// Span returns the tile's spans with zero values normalized to 1.
|
||||||
|
|||||||
@@ -45,6 +45,28 @@ func TestValidateTiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCameraStreamURL(t *testing.T) {
|
||||||
|
cam := Camera{Name: "c", Streams: map[string]string{"high": "H", "low": "L"}}
|
||||||
|
if got := cam.StreamURL("low"); got != "L" {
|
||||||
|
t.Errorf(`StreamURL("low") = %q, want "L"`, got)
|
||||||
|
}
|
||||||
|
if got := cam.StreamURL("high"); got != "H" {
|
||||||
|
t.Errorf(`StreamURL("high") = %q, want "H"`, got)
|
||||||
|
}
|
||||||
|
// Requested quality missing → fall down the preference list, then any.
|
||||||
|
if got := cam.StreamURL("medium"); got != "L" {
|
||||||
|
t.Errorf(`StreamURL("medium") = %q, want fallback "L"`, got)
|
||||||
|
}
|
||||||
|
if got := cam.StreamURL(""); got != "H" {
|
||||||
|
t.Errorf(`StreamURL("") = %q, want best "H"`, got)
|
||||||
|
}
|
||||||
|
// Legacy single-URL camera.
|
||||||
|
legacy := Camera{Name: "old", RTSP: "R"}
|
||||||
|
if got := legacy.StreamURL("low"); got != "R" {
|
||||||
|
t.Errorf("legacy StreamURL = %q, want RTSP fallback R", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateTilesCap(t *testing.T) {
|
func TestValidateTilesCap(t *testing.T) {
|
||||||
var tiles []Tile
|
var tiles []Tile
|
||||||
for i := 0; i < MaxTiles+1; i++ {
|
for i := 0; i < MaxTiles+1; i++ {
|
||||||
|
|||||||
@@ -122,13 +122,18 @@ func (d *Daemon) applyLayout(ctx context.Context, name string) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cam := cfg.CameraByName(tile.Camera)
|
cam := cfg.CameraByName(tile.Camera)
|
||||||
if cam == nil || cam.Disabled || cam.RTSP == "" {
|
if cam == nil || cam.Disabled {
|
||||||
d.log.Warn("skipping tile: camera unavailable", "slot", slot, "camera", tile.Camera)
|
d.log.Warn("skipping tile: camera unavailable", "slot", slot, "camera", tile.Camera)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
url := cam.StreamURL(tile.Quality)
|
||||||
|
if url == "" {
|
||||||
|
d.log.Warn("skipping tile: no stream url", "slot", slot, "camera", tile.Camera, "quality", tile.Quality)
|
||||||
|
continue
|
||||||
|
}
|
||||||
cs, rs := tile.Span()
|
cs, rs := tile.Span()
|
||||||
rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs)
|
rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs)
|
||||||
p := player.New(slot, cam.Name, cam.RTSP, cfg.Player, d.runDir, d.log)
|
p := player.New(slot, cam.Name, url, cfg.Player, d.runDir, d.log)
|
||||||
players = append(players, p)
|
players = append(players, p)
|
||||||
|
|
||||||
d.wg.Add(1)
|
d.wg.Add(1)
|
||||||
|
|||||||
@@ -81,11 +81,16 @@ func (p *Player) args() []string {
|
|||||||
"--title=" + p.Title(),
|
"--title=" + p.Title(),
|
||||||
"--input-ipc-server=" + p.ipcPath,
|
"--input-ipc-server=" + p.ipcPath,
|
||||||
"--hwdec=" + p.cfg.HWDec,
|
"--hwdec=" + p.cfg.HWDec,
|
||||||
// Live-stream hygiene: prefer TCP transport, keep buffers small.
|
// Live-stream hygiene: TCP transport, no caching, and drop frames when
|
||||||
|
// behind so latency self-corrects instead of accumulating a backlog.
|
||||||
"--rtsp-transport=tcp",
|
"--rtsp-transport=tcp",
|
||||||
"--profile=low-latency",
|
"--profile=low-latency",
|
||||||
"--cache=no",
|
"--cache=no",
|
||||||
"--demuxer-lavf-o=stimeout=5000000",
|
"--framedrop=decoder+vo",
|
||||||
|
"--demuxer-lavf-o=stimeout=5000000,fflags=+nobuffer,flags=+low_delay",
|
||||||
|
}
|
||||||
|
if p.cfg.MaxFPS > 0 {
|
||||||
|
args = append(args, fmt.Sprintf("--vf=fps=%d", p.cfg.MaxFPS))
|
||||||
}
|
}
|
||||||
if p.cfg.Profile != "" && p.cfg.Profile != "low-latency" {
|
if p.cfg.Profile != "" && p.cfg.Profile != "low-latency" {
|
||||||
args = append(args, "--profile="+p.cfg.Profile)
|
args = append(args, "--profile="+p.cfg.Profile)
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ func (m *model) updateLayoutEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.screen, m.cursor = screenCameraPicker, 0
|
m.screen, m.cursor = screenCameraPicker, 0
|
||||||
case "c":
|
case "c":
|
||||||
m.clearAt()
|
m.clearAt()
|
||||||
|
case "q": // cycle this tile's stream quality (high/low/auto)
|
||||||
|
m.cycleQuality()
|
||||||
case "L": // grow wider (toward the right)
|
case "L": // grow wider (toward the right)
|
||||||
m.resizeTile(1, 0)
|
m.resizeTile(1, 0)
|
||||||
case "H": // shrink narrower (from the right)
|
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,
|
// resizeTile grows/shrinks the tile under the cursor by the given span deltas,
|
||||||
// keeping it in-bounds and non-overlapping.
|
// keeping it in-bounds and non-overlapping.
|
||||||
func (m *model) resizeTile(dCol, dRow int) {
|
func (m *model) resizeTile(dCol, dRow int) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -95,8 +96,8 @@ type savedMsg struct {
|
|||||||
reloaded bool
|
reloaded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// discoverCmd fetches cameras. When enable is non-empty (e.g. "high"), it also
|
// discoverCmd fetches cameras. `enable` is a comma-separated list of qualities
|
||||||
// turns on RTSP in Protect for any camera that lacks an enabled channel.
|
// (e.g. "high,low") to ensure RTSP-enabled in Protect and record per camera.
|
||||||
func (m *model) discoverCmd(enable string) tea.Cmd {
|
func (m *model) discoverCmd(enable string) tea.Cmd {
|
||||||
cfg := m.cfg
|
cfg := m.cfg
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
@@ -119,10 +120,25 @@ func (m *model) discoverCmd(enable string) tea.Cmd {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return discoverMsg{err: err}
|
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
|
enabled := 0
|
||||||
if enable != "" {
|
for i := range cams {
|
||||||
names, _ := cl.EnableMissing(ctx, cams, enable)
|
cam := &cams[i]
|
||||||
enabled = len(names)
|
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}
|
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)
|
cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS)
|
||||||
added, updated, skipped := 0, 0, 0
|
added, updated, skipped := 0, 0, 0
|
||||||
for _, cam := range msg.cams {
|
for i := range msg.cams {
|
||||||
ch := cam.BestEnabledChannel()
|
cam := &msg.cams[i]
|
||||||
if ch == nil {
|
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++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
url := cl.StreamURL(ch.RTSPAlias)
|
entry := m.cameraByID(cam.ID)
|
||||||
if ex := m.cameraByID(cam.ID); ex != nil {
|
if entry == nil {
|
||||||
ex.Name, ex.RTSP = cam.Name, url
|
entry = m.cfg.CameraByName(cam.Name)
|
||||||
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++
|
|
||||||
}
|
}
|
||||||
|
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
|
m.dirty = true
|
||||||
enabledNote := ""
|
enabledNote := ""
|
||||||
if msg.enabled > 0 {
|
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.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.screen = screenCameras
|
||||||
@@ -229,7 +252,7 @@ var menuItems = []string{
|
|||||||
menuLayouts: "Layouts",
|
menuLayouts: "Layouts",
|
||||||
menuSetActive: "Set active layout",
|
menuSetActive: "Set active layout",
|
||||||
menuDiscover: "Discover from UniFi Protect",
|
menuDiscover: "Discover from UniFi Protect",
|
||||||
menuDiscoverEnable: "Discover + enable RTSP (high)",
|
menuDiscoverEnable: "Discover + enable RTSP (hi+lo)",
|
||||||
menuSave: "Save",
|
menuSave: "Save",
|
||||||
menuQuit: "Quit",
|
menuQuit: "Quit",
|
||||||
}
|
}
|
||||||
@@ -252,8 +275,8 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.setStatus("discovering…", false)
|
m.setStatus("discovering…", false)
|
||||||
return m, m.discoverCmd("")
|
return m, m.discoverCmd("")
|
||||||
case menuDiscoverEnable:
|
case menuDiscoverEnable:
|
||||||
m.setStatus("discovering and enabling RTSP…", false)
|
m.setStatus("discovering and enabling RTSP (high+low)…", false)
|
||||||
return m, m.discoverCmd("high")
|
return m, m.discoverCmd("high,low")
|
||||||
case menuSave:
|
case menuSave:
|
||||||
return m, m.save()
|
return m, m.save()
|
||||||
case menuQuit:
|
case menuQuit:
|
||||||
|
|||||||
@@ -107,13 +107,27 @@ func (m *model) viewLayoutEdit() string {
|
|||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString(strings.Join([]string{
|
b.WriteString(strings.Join([]string{
|
||||||
hkey("click", "assign cell") + " " + hkey("drag a tile", "resize") + " (mouse)",
|
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"),
|
"resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"),
|
||||||
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
|
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
|
||||||
}, "\n"))
|
}, "\n"))
|
||||||
return b.String()
|
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
|
// 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.
|
// for spanned cells, and a highlighted cursor cell — a live picture of the wall.
|
||||||
func (m *model) renderGrid(cols, rows int) string {
|
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)
|
plain = padTo(truncate(label, cellW), cellW)
|
||||||
case isOrigin && line == 1:
|
case isOrigin && line == 1:
|
||||||
cs, rs := t.Span()
|
cs, rs := t.Span()
|
||||||
|
info := ""
|
||||||
if cs > 1 || rs > 1 {
|
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
|
dim = true
|
||||||
default: // continuation cell of a spanned tile
|
default: // continuation cell of a spanned tile
|
||||||
plain = center("·", cellW)
|
plain = center("·", cellW)
|
||||||
|
|||||||
Reference in New Issue
Block a user