Files
fegger bbe4f21a3b docs: document the Android receiver app (Phase 8)
README: phone-as-second-screen section (build, install, sender usage,
multi-monitor caveat). RUNBOOK: Android build/test procedure, device-test
commands, and the API-36 platform quirks found during validation.
PHASES: Phase 8 milestones, all validated. MEMORY: current phase state
and the hard-won Android/portal lessons.
2026-09-10 11:38:16 +02:00

7.7 KiB

screen_cast

A native Linux peer-to-peer screencast application.

  • Send your desktop or a window to another Linux machine.
  • Receive a stream and render it in a window — or fullscreen headless.
  • Discover receivers on the LAN via mDNS/Avahi; negotiate sessions with JSON signaling; stream H.264 over RTP/UDP.
  • Control it from the CLI, a GTK4 panel, or a waybar widget.

Built with C++20, Meson, PipeWire, FFmpeg, and SDL3.

Quick start

Requirements:

  • C++20 compiler with <format> (GCC 13+, Clang 18+)
  • Meson >= 0.63, Ninja
  • FFmpeg development packages (libavcodec, libavutil, libswscale)
  • SDL3 development package (sdl3)
  • nlohmann JSON (nlohmann_json) and Avahi client (avahi-client)
  • For the sender only: PipeWire dev (libpipewire-0.3) and libportal
  • Optional, for the GUI: gtkmm-4.0

Build and run tests:

meson setup build
meson compile -C build
meson test -C build --print-errorlogs

Stream between two machines:

screencast --receive        # machine A: announces itself, opens a window
screencast --send           # machine B: discovers A, negotiates, streams

See docs/RUNBOOK.md for all modes, flags, and validation procedures.

Modes and flags

screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate MAX_KBPS] [--crf 0-51] [--fps 1-60]
screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]
screencast --discover [--timeout SECONDS]
screencast waybar [--toggle]        # for waybar widgets
Mode Flag Default Meaning
--send --target monitor monitor or window (portal source picker)
--peer auto-discover target a receiver directly, HOST[:PORT]
--bitrate 4000 VBV max bitrate (kbps)
--crf 22 encoder quality (lower = better)
--fps capture rate frame-rate cap, 1-60
--receive --port 5004 local RTP UDP port
--signaling-port 5005 TCP signaling port announced via mDNS
--fullscreen off force fullscreen (automatic under KMSDRM)
--swdecode off skip the hardware decode probe
--discover --timeout 3 seconds to wait for mDNS responses
waybar --toggle print status toggle streaming (stop / restart last session)

--send without --peer discovers receivers on the LAN and requires that exactly one is found; use --discover to list them.

GUI and waybar widget

Build the GUI alongside the CLI (meson configure build -Dgui=true, then recompile; installs as screencast-gui):

screencast-gui          # receiver list → pick one → bitrate → Start
screencast waybar       # one JSON line for a waybar custom module
screencast waybar --toggle

The waybar module shows the app icon (dimmed while idle, highlighted while streaming; tooltip: receiver, bitrate, elapsed); left-click toggles streaming to the last receiver, right-click opens the panel. All front-ends agree on state because the sender publishes it to $XDG_RUNTIME_DIR/screencast/sender.json. The icon (from screencast_icon/) is installed into the hicolor theme by meson install, used by the GTK panel and the waybar CSS, and embedded in the SDL receiver window.

Under the hood: resilience

The receiver absorbs loss in two stages and recovers actively:

  1. Jitter window: RTP packets are re-ordered by sequence number in a small buffer (16 packets / 60 ms), so Wi-Fi reordering is not misread as loss. In-order streams release immediately (zero added latency).
  2. PLI feedback: when a frame arrives genuinely damaged, the receiver drops it and asks the sender for a keyframe over the signaling channel (rate-limited to one request per 500 ms); the sender re-encodes a keyframe immediately, so recovery takes one frame time.

Hardware decode: the receiver probes h264_v4l2m2m (the VideoCore path on Raspberry Pi) and falls back to software automatically; --swdecode forces software.

Receiver on a small ARM board (e.g. Raspberry Pi Zero 2 W)

The receiver does not need the sender's PipeWire/portal capture stack. On the board, run:

sudo ./scripts/install-receiver.sh
screencast --receive

The script installs the dependencies, builds a receiver-only binary (-Dsender=false), runs the test suite, and installs to /usr/local/bin (override with SC_RECEIVER_INSTALL_DIR). It needs Raspberry Pi OS Trixie or newer (GCC 13+ for C++20 <format>), builds SDL3 from source when the distribution does not package it, and enables avahi-daemon. It also installs and enables a systemd service so the receiver starts at boot — systemctl status screencast-receiver to check on it (stop with sudo systemctl stop screencast-receiver; always SIGTERM, never kill -9, or stale mDNS records linger).

On a headless console the receiver runs fullscreen automatically with aspect-preserving letterboxing — no window manager needed (SDL3 KMSDRM; it renders on its own VT, tty7, so Ctrl+Alt+F1 returns to the console). See docs/RUNBOOK.md for the headless setup details.

The receiver can act as a Wi-Fi access point, so a sender connects to the board directly with no router in between:

sudo scripts/pi-hotspot.sh on        # prints SSID + generated password
sudo scripts/pi-hotspot.sh status   # shows the saved credentials
sudo scripts/pi-hotspot.sh off      # back to normal router Wi-Fi

While active the Pi is reachable at 10.42.0.1; on the sender, join the hotspot's Wi-Fi and run screencast --send.

Android receiver: a phone as the second screen

A native receiver app (Kotlin, minSdk 30) lets a phone act as the second receiver — and with a USB-C DisplayPort-alt-mode cable, the phone screen mirrors straight to HDMI. The app speaks the same signaling + RTP protocol, so the sender needs no changes.

cd android
gradle :app:assembleDebug          # or :app:installDebug with a device attached
adb install app/build/outputs/apk/debug/app-debug.apk

Launch the app (it advertises _screencast._tcp and shows its IP + ports), then on the sender:

screencast --send --peer <phone-ip>:5005

Notes:

  • The sender downscales to the phone's display resolution automatically (from the signaling answer).
  • The phone renders letterboxed (fit-within), fullscreen, screen kept on.
  • The app needs only the INTERNET permission; no camera/location.
  • mDNS discovery works on the same L2 segment; across subnets use --peer (the app prints its IP and the exact fallback command).
  • --target monitor captures the portal's default output — on multi-monitor Hyprland/GTK-portal setups that may not be the one you want; use --target window and pick a window on the target display.

Architecture

See docs/ARCHITECTURE.md for module boundaries and design rules.

Roadmap

Development is split into phases in docs/PHASES.md.

Current phase: Phase 8 — Android receiver app (complete): the Kotlin receiver app streams to a phone (validated on a Fairphone 6, phone → HDMI via USB-C DP-alt-mode). Phase 7 items (PLI, jitter, hardware decode, GUI/waybar) are complete and validated; deferred items remain the VAAPI hardware encode probe and packaging.

License

MIT — see LICENSE (to be added).