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.
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:
- 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).
- 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.
Direct link: receiver as a Wi-Fi hotspot
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
INTERNETpermission; 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 monitorcaptures the portal's default output — on multi-monitor Hyprland/GTK-portal setups that may not be the one you want; use--target windowand 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).