Add --keepaspect-window=no so mpv stops reshaping its window to the video aspect (which pushed a 4:3 camera below its tile and hid the bottom behind the neighbour). Combined with keepaspect=yes the video is letterboxed to fit the tile for any camera type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rtsp-streamer
A full-screen RTSP video wall for UniFi Protect cameras, built for a Raspberry
Pi 4 (or any Linux box) plugged into a TV. No Chrome kiosk, no desktop — just
mpv tiles under a minimal Wayland compositor, driven by a single Go binary
you configure over SSH from the command line or an interactive TUI.
Why this design
- One
mpvprocess per camera. A dead or unreachable camera never takes down the wall — the daemon restarts only the slot that failed, with backoff. (Contrast with a single ffmpeg/mpvmosaic, where one stalled input can freeze the whole picture.) - Compositor tiling, not fullscreen. A tiny sway session hosts the windows; the daemon positions each one at an exact pixel rectangle over sway's IPC. No X11, no browser, no GPU compositor tricks.
- Hardware decode.
mpv --hwdec=auto-safeuses the Pi 4's V4L2/DRM H.264 decoder. - Stateful config. A single hand-editable
config.yaml, or edit it live withrtsp-streamer tui. Switching layouts is instant and doesn't restart anything you don't have to.
Pi 4 (headless, HDMI -> TV)
┌───────────────────────────────────────────────┐
│ tty1 autologin -> sway -> rtsp-streamer daemon │
│ ├─ reads active layout -> computes grid rects │
│ ├─ spawns 1 mpv per slot (--hwdec, IPC sock) │
│ ├─ swaymsg: float + position each to a cell │
│ └─ health-monitors mpv, restarts dead streams │
│ │
│ control socket ◀── rtsp-streamer layout set … │
│ ◀── rtsp-streamer tui (over SSH) │
└───────────────────────────────────────────────┘
▲ discover
UniFi Protect controller (login -> bootstrap -> RTSPS URLs)
Requirements
On the display device:
swayandmpv- optional: a terminal like
footfor the emergency keybinding
Debian / Raspberry Pi OS: sudo apt install 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).
Build
make build # native binary -> bin/rtsp-streamer
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 test
Configure
rtsp-streamer config init # write a starter ~/.config/rtsp-streamer/config.yaml
export RTSP_STREAMER_PASSWORD=… # controller password (kept out of the file)
$EDITOR "$(rtsp-streamer config path)" # set controller.host / username
rtsp-streamer discover # pull cameras + RTSPS URLs from UniFi Protect
rtsp-streamer tui # assign cameras to layout slots, pick active
See config.example.yaml for the full schema. The
config path is $XDG_CONFIG_HOME/rtsp-streamer/config.yaml (i.e.
~/.config/rtsp-streamer/config.yaml), overridable with --config or
$RTSP_STREAMER_CONFIG.
Layouts (grid + spanning tiles)
A layout is a base grid (COLSxROWS, up to 8×8) on which each camera is a
tile that can span multiple cells. That covers a plain 2×2/3×3/4×4 as well
as security-wall arrangements — one big main view plus a column of small ones,
2+6, etc. A layout can show up to 16 cameras (decoding more than that on a
Pi 4 isn't practical even with substreams). Tiles must stay inside the grid and
may not overlap. See main-plus in config.example.yaml.
The TUI
rtsp-streamer tui is a Bubble Tea configurator meant to be run over SSH:
- Cameras — review discovered cameras,
ddisable,xdelete. - Layouts — edit an arrangement on a live ASCII grid preview.
- Set active layout — choose what the wall shows.
- Discover / Discover + enable RTSP (high) — pull cameras from Protect.
- Save — writes the config and tells a running daemon to reload live.
In the layout editor the grid is drawn live as you edit:
+----------+----------+----------+----------+
|Front Door| · | · |Driveway |
|3x3 | · | · | |
+----------+----------+----------+----------+
| · | · | · |Back Yard |
...
- move cursor: arrows or
h/j/k/l enterassign a camera to the cell (pick(empty)to clear) ·cclear- resize the tile under the cursor:
L/Hwider/narrower,J/Ktaller/shorter - resize the base grid:
]/[add/remove a column,}/{add/remove a row escback
Elsewhere: arrows / j/k move, enter selects, esc goes back, q quits.
Run
rtsp-streamer daemon # normally launched by sway, not by hand
rtsp-streamer status # slot health from the running daemon
rtsp-streamer layout ls # list layouts (* marks active)
rtsp-streamer layout set quad # switch live (persists the choice)
Deploy to a Pi (boot straight to the wall)
make pi
scp bin/rtsp-streamer-arm64 pi@display:/tmp/
# on the Pi, put the binary where install.sh expects it and run the installer:
# mv /tmp/rtsp-streamer-arm64 <repo>/bin/rtsp-streamer
sudo ./deploy/install.sh kiosk
install.sh installs the binary, drops the kiosk sway config at
/etc/rtsp-streamer/sway/config, creates the kiosk user, enables tty1
autologin, and appends a launcher to the user's ~/.bash_profile that starts
sway on the physical console only (SSH sessions still get a normal shell).
Reboot and the wall comes up.
Provide the controller password to the daemon by sourcing an env file from the
kiosk user's ~/.bash_profile before the sway launcher, e.g.:
# ~/.bash_profile (kiosk user)
[ -f ~/.config/rtsp-streamer/env ] && . ~/.config/rtsp-streamer/env
# >>> rtsp-streamer kiosk launcher >>> (added by install.sh)
Enabling RTSP in UniFi Protect
Protect does not expose RTSP until you turn it on per camera. You can do it by hand (Protect → camera → Settings → Advanced → RTSP, enable a channel), or let rtsp-streamer flip it on for you via the API:
rtsp-streamer discover --enable-rtsp=high # enable the High channel where missing
rtsp-streamer discover --enable-rtsp=low # or the Low substream
In the TUI, the menu item "Discover + enable RTSP (high)" does the same.
Enabling preserves each channel's other encoder settings — it only flips the
isRtspEnabled flag. Plain discover (no flag) never modifies your controller;
it just skips cameras with no RTSP-enabled channel and reports which ones.
Use discover --low-res (or --enable-rtsp=low) to prefer substreams for dense
grids (3x3+), which greatly cuts Pi decode load.
Troubleshooting
- Blank/black tile — verify the stream directly:
mpv --rtsp-transport=tcp 'rtsps://HOST:7441/ALIAS?enableSrtp'. If that fails, RTSP probably isn't enabled for that camera (see above). swaymsg not found/ windows not placed — the daemon must run inside a sway session (started by the kiosk launcher). Checkecho $SWAYSOCK.- High CPU / dropped frames on a 3x3 — switch those cameras to substreams
(
discover --low-res) and/or addplayer.extra_args: ["--vf=fps=15"]. login failed— use a local Protect account (not a Ubiquiti SSO login) and confirmcontroller.hostreaches the UniFi OS console.
Caveats & scope
- Unofficial API. UniFi Protect has no public API; the local endpoints used
here (
/api/auth/login,/proxy/protect/api/bootstrap) are the same stable ones the Home Assistant integration relies on, but Ubiquiti could change them. Manual RTSP URLs incameras:work with any camera brand and need no controller. - Linux/Wayland only. "Other platforms" means other Linux devices (x86 mini PCs, other SBCs) — anywhere sway + mpv run. It is not a macOS/Windows app.
- First cut ships preset grids. Auto-cycling/rotation and free-form positioning are natural next steps (the daemon already re-tiles on demand).
Layout
cmd/rtsp-streamer/ CLI (cobra): daemon, discover, layout, status, tui, config
internal/config/ YAML load/save/validate, schema, XDG paths
internal/protect/ UniFi Protect client (login, bootstrap, RTSPS URLs)
internal/player/ one supervised mpv per stream, JSON IPC, restart/backoff
internal/compositor/ sway control + NxM grid geometry
internal/daemon/ orchestrator + control socket + health loop
internal/ipc/ control-socket protocol shared by daemon and CLI/TUI
internal/tui/ Bubble Tea configurator
deploy/ sway kiosk config, autologin, install.sh