Compare commits

...

4 Commits

Author SHA1 Message Date
fegger eb86905e67 fix(app): build waybar output with nlohmann/json, not format strings
The hand-rolled std::format strings embedded literal newline and
Unicode characters via C++ universal character name escapes (\u23f8,
\u2014, \u25b6, \n), which the compiler converts to actual control
characters in the output. Raw newlines inside JSON strings are
invalid, so waybar's parser failed and displayed the raw JSON text
instead of the widget. Build the status line with nlohmann::json,
which escapes everything correctly.
2026-09-08 20:51:24 +02:00
fegger 5c39662cc2 feat(gui): add a GTK sender panel and a waybar widget
A gtkmm-4.0 control panel (behind -Dgui=true, default off): refresh
shows discovered receivers (grouped and preference-sorted), a bitrate
scale, and start/stop that runs the whole session on a worker thread
so the interactive portal picker never blocks the UI. The CLI and the
GUI now share the new sc_app_core static library holding the
pipelines, session orchestration (negotiation + PLI feedback), and a
state store.

The sender pipeline publishes its state to
$XDG_RUNTIME_DIR/screencast/sender.json (session id, receiver,
bitrate, pid, start time; stale files detected by pid liveness) and
persists the last session for one-click restarts. The new
'screencast waybar' subcommand prints a waybar module line and its
--toggle flag stops a running sender gracefully or spawns a detached
restart of the last receiver.

Waybar on the dev machine is wired: custom/screencast module with
click-to-toggle and right-click panel, plus styles, with a timestamped
backup of both config files. Both binaries are installed to
/usr/local/bin.

Validated: waybar output (idle and streaming states with a synthetic
state file), GUI launches on the desktop (window observed via
hyprctl), meson test 5/5 in both build configurations, formatting
clean.
2026-09-08 17:14:44 +02:00
fegger 74b3f04082 docs: record Phase 7 resilience state and re-scope 2026-09-08 16:54:09 +02:00
fegger 943596da6d feat(app): add PLI feedback, jitter reordering, and hardware decode
Loss recovery for the streaming path:

- PLI over signaling: the depacketizer now reports damaged frames
  (DepacketizeResult) and the receiver asks the sender for a keyframe
  (SessionPli, rate-limited to one per 500 ms). The sender keeps the
  signaling channel open during the session and honors PLIs through
  the new thread-safe SenderPipeline::request_keyframe(). Recovery
  takes one frame time instead of waiting out the GOP.
- RtpJitterBuffer: reorders RTP packets by sequence number (16 packets
  / 60 ms) before the in-order depacketizer, so Wi-Fi reordering is
  not misread as loss; in-order streams release immediately, and a
  straggler older than the delivered sequence is discarded.
- Hardware H.264 decode probe: DecoderFactory tries h264_v4l2m2m (the
  VideoCore path on the Pi) with an automatic software fallback and a
  clear journal line for the chosen path; --swdecode opts out.

Validated: PLI end-to-end with a probe that drops a mid-keyframe
packet over real UDP (receiver logged the damaged frame and the PLI
arrived with the session id); hardware probe fails cleanly and falls
back on this desktop; jitter reordering covered by unit tests.
meson test 5/5 in both build configurations, valgrind clean.
2026-09-08 16:54:09 +02:00
27 changed files with 1375 additions and 222 deletions
+27 -1
View File
@@ -65,12 +65,38 @@ loopback; current phase is Phase 7.
before the fix). Cross-compiling on the dev machine was considered and
dropped — the on-Pi build works and the toolchain/container effort was
not needed.
- **Pi Wi-Fi hotspot** (`scripts/pi-hotspot.sh on|off|status`): NetworkManager
- **GTK panel + waybar widget**: `screencast-gui` (gtkmm-4.0, behind
`-Dgui=true`; app internals now live in the `sc_app_core` static lib so
CLI and GUI share pipelines/session/state). `screencast waybar [--toggle]`
prints the waybar module line and toggles streaming via SIGTERM (stop) or
a detached re-exec spawn from `last-session.json` (start). State lives in
`$XDG_RUNTIME_DIR/screencast/sender.json` (written by the pipeline with
session id, receiver, bitrate, pid, start time; stale files are detected
by pid-liveness). The waybar config was wired into the user's bar
(`custom/screencast` before `custom/timetrack`, with backup) and the
binaries installed to /usr/local/bin.
- **Pi Wi-Fi hotspot** (`scripts/pi-hotspot.sh on|off|status`): NetworkManager
AP mode (WPA2, ipv4 shared → built-in DHCP/NAT, Pi at 10.42.0.1). Takes
over wlan0 while active; generated PSK stored in /etc/screencast-hotspot.conf.
No application changes needed — the receiver already announces on all
interfaces. nmcli property syntax validated against NM 1.58 with a
disposable profile; AP bring-up itself can only be validated on the Pi.
- **Phase 7 resilience shipped** (pending Pi-side hw-decode run):
- PLI over signaling: `SessionPli` message; the depacketizer now returns
`DepacketizeResult{access_unit, frame_dropped}`; the receiver
rate-limits PLIs to 1/500 ms; the sender keeps the signaling channel
open and calls `SenderPipeline::request_keyframe()` (thread-safe
atomic → run-loop → encoder). Validated end-to-end with a probe that
drops a mid-keyframe packet: receiver logs "frame damaged",
PLI arrives with the session id.
- `RtpJitterBuffer` (rtp_packet.h): sequence reordering, 16 pkt/60 ms,
straggler discard via serial-number arithmetic, overflow flush for
genuine loss. Zero added latency on in-order streams.
- Hardware decode probe in DecoderFactory (`DecoderConfig.hardware_accel`,
default true): h264_v4l2m2m first, software fallback, `--swdecode` opts
out. On the desktop the probe fails cleanly ("Could not find a valid
device") and falls back; on the Pi it should pick the VideoCore m2m
device — NEEDS THE USER'S PI RUN to confirm.
- **REAL-HARDWARE VALIDATION (desktop → Pi Zero 2 W over Wi-Fi)**: the full
chain works on two machines: mDNS discovery → signaling negotiation →
RTP over Wi-Fi → software H.264 decode → fullscreen KMSDRM letterboxed
+16 -7
View File
@@ -82,16 +82,25 @@ discovered addresses in reachability order (private IPv4 first).
## Phase 7 — Resilience and Polish
**Goal**: loss recovery, hardware acceleration, and packaging.
**Goal**: loss recovery, hardware acceleration, and polish.
- NACK / PLI feedback loop.
- Jitter buffer on the receiver.
- VAAPI/NVENC hardware encode probes and fallback.
- Optional GUI target behind `meson -Dgui=true`.
- `.desktop` file, icon, packaging notes.
- [x] PLI keyframe feedback over signaling (receiver asks, sender
re-encodes a keyframe; validated end-to-end with an induced loss).
- [x] Jitter/reorder window on the receiver (16 packets / 60 ms).
- [x] Hardware H.264 decode probe with software fallback (h264_v4l2m2m —
the Pi's VideoCore path; --swdecode opts out).
- [x] GTK4 sender GUI behind `meson -Dgui=true` (gtkmm; receiver list,
bitrate, start/stop) plus a waybar widget (`screencast waybar`,
click-to-toggle, right-click opens the panel; state shared via
$XDG_RUNTIME_DIR/screencast/sender.json).
- [ ] VAAPI hardware encode probe on the sender (deferred: software
encode is not the bottleneck).
- Deferred: .desktop file, packaging.
**Validation**: sustained streaming under packet loss; hardware accel smoke
where available.
where available. PLI + jitter validated on the desktop (induced-loss probe);
hardware decode validated as clean-fallback on the desktop, hardware path
pending a run on the Pi.
## Current phase
+35
View File
@@ -134,6 +134,41 @@ A window manager only comes with the desktop-session alternative, where
the receiver runs inside it (uncomment the `Environment=` lines in the
service file as described above).
## GTK panel and waybar widget (Phase 7)
Build the GUI alongside the CLI (`meson configure build -Dgui=true`, then
recompile; installs as `screencast-gui`):
```sh
screencast-gui # receiver list → pick one → bitrate → Start
screencast waybar # one JSON line for a waybar custom module
screencast waybar --toggle # stop a running sender / restart the last
```
The waybar module (installed to this machine's `~/.config/waybar/` — see
`custom/screencast` there): `▶` while streaming (tooltip: receiver, bitrate,
elapsed), `⏸` while idle; left-click toggles streaming to the last receiver
via the state file, right-click opens the panel. The sender publishes its
state to `$XDG_RUNTIME_DIR/screencast/sender.json`, so every front-end —
CLI, GUI, widget — agrees on what is running.
## Under the hood: resilience (Phase 7)
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 — recovery takes one frame time instead of
waiting out the rest of the GOP.
**Hardware decode**: the receiver probes `h264_v4l2m2m` (the VideoCore
path on Raspberry Pi) and falls back to software automatically; the
journal says which path is active. `--swdecode` forces software.
## Sender / receiver loopback (Phase 5, manual)
Two terminals on the same desktop session:
+6 -1
View File
@@ -18,13 +18,18 @@ struct ReceiveCommand {
int local_rtp_port = 5004;
int signaling_port = 5005;
bool fullscreen = false;
bool software_decode = false; // --swdecode disables the hardware probe
};
struct DiscoverCommand {
int timeout_seconds = 3;
};
using Command = std::variant<SendCommand, ReceiveCommand, DiscoverCommand>;
struct WaybarCommand {
bool toggle = false; // toggle streaming instead of printing status
};
using Command = std::variant<SendCommand, ReceiveCommand, DiscoverCommand, WaybarCommand>;
// Parse command line arguments. Prints usage and returns std::nullopt on error.
// `argv` is `char const* const*` so both `main`'s `char**` and const arrays
+7
View File
@@ -19,6 +19,9 @@ struct SenderPipelineConfig {
// Where encoded RTP packets are sent. Defaults to the local loopback so
// a sender and receiver on one machine work without any configuration.
Endpoint peer_rtp_endpoint{"127.0.0.1", 5004};
// Session identity from signaling; written to the sender state file for
// status widgets (waybar) and one-click restarts.
std::string session_id;
};
struct ReceiverPipelineConfig {
@@ -39,6 +42,10 @@ class SenderPipeline {
bool start();
void stop();
// Ask the sender to encode its next frame as a keyframe. Thread-safe;
// used by the PLI feedback path.
void request_keyframe();
private:
class Impl;
std::unique_ptr<Impl> impl_;
+3
View File
@@ -22,6 +22,9 @@ struct DecoderConfig {
int width = 0;
int height = 0;
std::vector<std::byte> extradata; // SPS/PPS for H.264
// Probe a hardware decoder (v4l2 mem2mem) first and fall back to the
// software decoder automatically. Disable with --swdecode.
bool hardware_accel = true;
};
class Decoder {
+13 -4
View File
@@ -42,14 +42,23 @@ class H264Packetizer {
std::uint16_t next_sequence_number_ = 0;
};
// Result of feeding one packet to the depacketizer.
struct DepacketizeResult {
// The completed access unit (Annex-B with 3-byte start codes) when the
// packet closed an undamaged frame.
std::optional<std::vector<std::byte>> access_unit;
// True when this call discarded a frame as damaged (packet loss or an
// unsupported packetization). Pipelines use it to request a keyframe.
bool frame_dropped = false;
};
// Reassembles RFC 6184 packet streams (single NAL unit packets and FU-A)
// into Annex-B access units. Packets must arrive in order; frames damaged by
// sequence gaps or missing fragments are dropped silently.
// sequence gaps or missing fragments are reported via DepacketizeResult.
class H264Depacketizer {
public:
// Feed one packet. Returns the completed access unit (Annex-B with 3-byte
// start codes) when the packet closes a frame, nullopt otherwise.
std::optional<std::vector<std::byte>> depacketize(const RtpPacket& packet);
// Feed one packet.
DepacketizeResult depacketize(const RtpPacket& packet);
private:
void drop_frame();
+29
View File
@@ -1,8 +1,12 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <map>
#include <mutex>
#include <optional>
#include <span>
#include <utility>
#include <vector>
namespace sc {
@@ -31,4 +35,29 @@ struct RtpPacket {
static std::optional<RtpPacket> parse(std::span<const std::byte> in) noexcept;
};
// Reorders RTP packets by sequence number before depacketization so that a
// reordering link (Wi-Fi) does not read as loss. Delivery stays in order;
// only aged-out or overflowing buffers release out of order, which the
// downstream gap detection still handles for genuine loss.
class RtpJitterBuffer {
public:
explicit RtpJitterBuffer(std::size_t max_depth = 16,
std::chrono::milliseconds max_delay = std::chrono::milliseconds{60});
// Insert one packet and return the packets now ready for in-order
// delivery. In-order streams release immediately (zero added latency);
// a straggler older than the next expected sequence is discarded.
std::vector<RtpPacket> push(RtpPacket packet);
// Discard everything still buffered.
void clear();
private:
std::size_t max_depth_;
std::chrono::milliseconds max_delay_;
std::mutex mutex_;
std::map<std::uint16_t, std::pair<std::chrono::steady_clock::time_point, RtpPacket>> buffer_;
std::optional<std::uint16_t> next_expected_;
};
} // namespace sc
+7 -1
View File
@@ -31,7 +31,13 @@ struct SessionAnswer {
Endpoint rtp_endpoint;
};
using SignalingMessage = std::variant<SessionOffer, SessionAnswer>;
// Picture Loss Indication: the receiver asks the sender for a keyframe
// after discarding a damaged frame.
struct SessionPli {
std::string session_id;
};
using SignalingMessage = std::variant<SessionOffer, SessionAnswer, SessionPli>;
class SignalingChannel {
public:
+17 -1
View File
@@ -9,8 +9,9 @@ namespace {
void print_usage() {
std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate KBPS]\n"
" screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen]\n"
" screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]\n"
" screencast --discover [--timeout SECONDS]\n"
" screencast waybar [--toggle] # for waybar widgets\n"
"\n"
"--send without --peer discovers a receiver on the LAN and requires\n"
"that exactly one is found.\n",
@@ -39,12 +40,14 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
Send,
Receive,
Discover,
Waybar,
};
Mode mode = Mode::None;
SendCommand send;
ReceiveCommand receive;
DiscoverCommand discover;
WaybarCommand waybar;
for (int index = 1; index < argc; ++index) {
const std::string_view argument = argv[index];
@@ -67,6 +70,14 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
return std::nullopt;
}
mode = Mode::Discover;
} else if (argument == "waybar") {
if (mode != Mode::None) {
print_usage();
return std::nullopt;
}
mode = Mode::Waybar;
} else if (argument == "--toggle") {
waybar.toggle = true;
} else if (argument == "--target") {
std::string_view value;
if (!next_argument(argc, argv, index, value)) {
@@ -112,6 +123,8 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
receive.signaling_port = port;
} else if (argument == "--fullscreen") {
receive.fullscreen = true;
} else if (argument == "--swdecode") {
receive.software_decode = true;
} else if (argument == "--timeout") {
std::string_view value;
if (!next_argument(argc, argv, index, value) || !parse_int(value, discover.timeout_seconds) ||
@@ -134,6 +147,9 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
if (mode == Mode::Discover) {
return Command{std::move(discover)};
}
if (mode == Mode::Waybar) {
return Command{std::move(waybar)};
}
print_usage();
return std::nullopt;
}
+154 -169
View File
@@ -1,9 +1,17 @@
#include "screencast/app/cli.h"
#include "screencast/app/pipeline.h"
#include "sender_session.h"
#include "state_store.h"
#include "screencast/network/discovery.h"
#include "screencast/network/signaling.h"
#include <nlohmann/json.hpp>
#include <sys/types.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <charconv>
@@ -53,49 +61,6 @@ sc::Endpoint parse_endpoint(std::string_view address, std::uint16_t default_port
#endif // SC_HAS_SENDER
std::string make_session_id() {
static std::mt19937 engine{std::random_device{}()};
std::string id;
for (int i = 0; i < 16; ++i) {
id += "0123456789abcdef"[static_cast<std::size_t>(engine()) & 0xF];
}
return id;
}
bool is_private_ipv4(std::string_view host) {
if (host.rfind("192.168.", 0) == 0 || host.rfind("10.", 0) == 0) {
return true;
}
if (host.rfind("172.", 0) == 0) {
const std::size_t second = host.find('.', 5);
if (second != std::string_view::npos) {
const int octet = std::stoi(std::string{host.substr(5, second - 5)});
return octet >= 16 && octet <= 31;
}
}
return false;
}
// Ordering for trying a receiver's addresses: private IPv4 first (LANs,
// most reliable), then public IPv4, ULA, and global IPv6. 6to4 (2002::) and
// link-local addresses last: 6to4 is frequently unreachable between LAN
// peers, and link-local needs a scope id to even route.
int address_preference(std::string_view host) {
if (host.find(':') == std::string_view::npos) {
return is_private_ipv4(host) ? 0 : 1;
}
if (host.rfind("fd", 0) == 0 || host.rfind("fc", 0) == 0) {
return 2;
}
if (host.rfind("2002:", 0) == 0) {
return 4;
}
if (host.rfind("fe80:", 0) == 0) {
return 5;
}
return 3;
}
std::vector<sc::DiscoveredPeer> discover_peers(int timeout_seconds, std::string& error) {
std::vector<sc::DiscoveredPeer> peers;
std::mutex mutex;
@@ -140,155 +105,172 @@ std::vector<sc::DiscoveredPeer> discover_peers(int timeout_seconds, std::string&
#ifdef SC_HAS_SENDER
// Offer, wait for the answer, and stream to the negotiated endpoint. The
// channel must already be connected.
int negotiate_and_stream(sc::SignalingChannel& channel, const sc::Endpoint& signaling, const sc::SendCommand& command) {
std::promise<sc::SessionAnswer> answer_promise;
auto answer_future = answer_promise.get_future();
std::atomic<bool> answered{false};
channel.on_message([&](const sc::SignalingMessage& message) {
if (const sc::SessionAnswer* answer = std::get_if<sc::SessionAnswer>(&message)) {
if (!answered.exchange(true)) {
answer_promise.set_value(*answer);
}
}
});
sc::SessionOffer offer;
offer.session_id = make_session_id();
offer.codec_name = "h264";
offer.frame_rate_num = 25;
offer.frame_rate_den = 1;
channel.send(offer);
if (answer_future.wait_for(std::chrono::seconds(5)) != std::future_status::ready) {
std::cerr << "screencast: the receiver did not answer the session offer\n";
channel.disconnect();
return 1;
}
const sc::SessionAnswer answer = answer_future.get();
channel.disconnect();
if (answer.session_id != offer.session_id) {
std::cerr << "screencast: session mismatch in the receiver's answer\n";
return 1;
}
// Stream to the negotiated RTP endpoint. An empty address means
// "the address you reached me on".
const sc::Endpoint rtp_endpoint = answer.rtp_endpoint.address.empty()
? sc::Endpoint{signaling.address, answer.rtp_endpoint.port}
: answer.rtp_endpoint;
sc::SenderPipelineConfig config;
if (command.target == "window") {
config.capture_target = sc::CaptureTargetWindow{};
} else {
config.capture_target = sc::CaptureTargetWholeScreen{};
}
config.peer_rtp_endpoint = rtp_endpoint;
config.encoder.bitrate_kbps = command.bitrate_kbps;
std::cout << std::format("screencast: session {} established; streaming to {}:{}\n",
offer.session_id,
rtp_endpoint.address,
rtp_endpoint.port);
sc::SenderPipeline pipeline{std::move(config)};
if (!pipeline.start()) {
return 1;
}
while (!g_interrupted.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
pipeline.stop();
return 0;
}
int run_sender(const sc::SendCommand& command) {
// Find the receiver's signaling endpoint: explicit --peer, or discover
// exactly one receiver on the LAN. A receiver may resolve to several
// addresses; try them in reachability order until the signaling
// connection succeeds.
auto channel_result = sc::SignalingFactory::create_client();
if (sc::is_network_error(channel_result)) {
std::cerr << std::format("screencast: {}\n", sc::network_error(channel_result).message);
return 1;
}
auto channel = std::move(sc::network_value(channel_result));
sc::Endpoint signaling;
sc::CaptureTarget target = sc::CaptureTargetWholeScreen{};
if (command.target == "window") {
target = sc::CaptureTargetWindow{};
}
if (!command.peer_address.empty()) {
signaling = parse_endpoint(command.peer_address, kDefaultSignalingPort);
if (!channel->connect(signaling)) {
std::cerr << std::format(
"screencast: failed to connect to the receiver at {}:{}\n", signaling.address, signaling.port);
} else {
std::string error;
std::vector<sc::DiscoveredPeer> peers = discover_peers(kSenderDiscoveryTimeoutSeconds, error);
if (!error.empty()) {
std::cerr << std::format("screencast: discovery failed: {}\n", error);
return 1;
}
return negotiate_and_stream(*channel, signaling, command);
}
std::string error;
std::vector<sc::DiscoveredPeer> peers = discover_peers(kSenderDiscoveryTimeoutSeconds, error);
if (!error.empty()) {
std::cerr << std::format("screencast: discovery failed: {}\n", error);
return 1;
}
if (peers.empty()) {
std::cerr << "screencast: no receiver found on the LAN; run 'screencast --receive' on the "
"target machine, or pass --peer\n";
return 1;
}
// Group the addresses by receiver (name + signaling port): one entry
// per address, but they are all the same host.
std::map<std::pair<std::string, std::uint16_t>, std::vector<std::string>> receivers;
for (const sc::DiscoveredPeer& peer : peers) {
receivers[{peer.service_name, peer.signaling_port}].push_back(peer.host);
}
if (receivers.size() > 1) {
for (const auto& [key, hosts] : receivers) {
std::cerr << std::format(
"screencast: {} at {}\n",
key.first,
std::accumulate(hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) {
return lhs.empty() ? rhs : lhs + ", " + rhs;
}));
if (peers.empty()) {
std::cerr << "screencast: no receiver found on the LAN; run 'screencast --receive' on the "
"target machine, or pass --peer\n";
return 1;
}
std::cerr << "screencast: multiple receivers found; pass --peer to choose one\n";
return 1;
}
auto [name, port] = receivers.begin()->first;
std::vector<std::string> hosts = receivers.begin()->second;
std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) {
return address_preference(lhs) < address_preference(rhs);
});
std::cout << std::format("screencast: found receiver '{}'\n", name);
bool connected = false;
std::string tried;
for (const std::string& host : hosts) {
if (channel->connect(sc::Endpoint{host, port})) {
signaling = sc::Endpoint{host, port};
connected = true;
break;
// Group the addresses by receiver (name + signaling port): one entry
// per address, but they are all the same host.
std::map<std::pair<std::string, std::uint16_t>, std::vector<std::string>> receivers;
for (const sc::DiscoveredPeer& peer : peers) {
receivers[{peer.service_name, peer.signaling_port}].push_back(peer.host);
}
tried += (tried.empty() ? "" : ", ") + host;
if (receivers.size() > 1) {
for (const auto& [key, hosts] : receivers) {
std::cerr << std::format(
"screencast: {} at {}\n",
key.first,
std::accumulate(
hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) {
return lhs.empty() ? rhs : lhs + ", " + rhs;
}));
}
std::cerr << "screencast: multiple receivers found; pass --peer to choose one\n";
return 1;
}
auto [name, port] = receivers.begin()->first;
std::vector<std::string> hosts = receivers.begin()->second;
std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) {
return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs);
});
std::cout << std::format("screencast: found receiver '{}'\n", name);
signaling = sc::Endpoint{hosts.front(), port};
}
if (!connected) {
std::cerr << std::format("screencast: could not reach the receiver (tried {})\n", tried);
auto session_result = sc::SenderSession::start(signaling, command.bitrate_kbps, target);
if (auto* error = std::get_if<std::string>(&session_result)) {
std::cerr << std::format("screencast: {}\n", *error);
return 1;
}
return negotiate_and_stream(*channel, signaling, command);
auto session = std::move(std::get<sc::SenderSession>(session_result));
std::cout << std::format("screencast: session {} streaming to {}\n", session.session_id(), session.receiver());
while (!g_interrupted.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
session.stop();
return 0;
}
#endif // SC_HAS_SENDER
#ifdef SC_HAS_SENDER
void spawn_detached_sender(const std::string& peer, int bitrate_kbps) {
char self_path[4096] = {};
const ssize_t length = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1);
if (length <= 0) {
return;
}
self_path[length] = '\0';
const pid_t child = fork();
if (child != 0) {
return; // parent returns immediately; the child streams detached
}
setsid();
(void)freopen("/dev/null", "w", stdout);
const std::string bitrate = std::to_string(bitrate_kbps);
(void)execl(self_path,
"screencast",
"--send",
"--peer",
peer.c_str(),
"--bitrate",
bitrate.c_str(),
static_cast<char*>(nullptr));
_exit(127);
}
#endif
void print_waybar_status() {
const auto state = sc::read_sender_state();
// Built with nlohmann::json so all escaping (newlines in tooltips,
// non-ASCII characters) is handled correctly. Hand-rolled format
// strings produced literal control characters that broke waybar's
// JSON parser.
nlohmann::json json = nlohmann::json::object();
if (!state.has_value()) {
json["text"] = "\u23f8"; // pause symbol
json["alt"] = "idle";
json["class"] = "idle";
json["tooltip"] = "screencast idle \u2014 click to stream to the last receiver\nright-click: open the panel";
std::cout << json.dump() << '\n';
return;
}
const std::int64_t elapsed_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch())
.count() -
state->started_epoch_ms;
const std::int64_t minutes = elapsed_ms / 60000;
const std::int64_t seconds = (elapsed_ms / 1000) % 60;
json["text"] = "\u25b6"; // play symbol
json["alt"] = "streaming";
json["class"] = "streaming";
json["tooltip"] = std::format("screencast \u2192 {}\n{} kbps \u00b7 {}m {:02}s\nsession {}",
state->receiver,
state->bitrate_kbps,
minutes,
seconds,
state->session_id);
std::cout << json.dump() << '\n';
}
int run_waybar(const sc::WaybarCommand& command) {
if (command.toggle) {
if (const auto state = sc::read_sender_state(); state.has_value()) {
(void)kill(static_cast<pid_t>(state->pid), SIGTERM);
// Give the graceful shutdown time to withdraw its state file so
// the immediately following status print reflects reality.
for (int attempt = 0; attempt < 20; ++attempt) {
if (!sc::read_sender_state().has_value()) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
} else {
#ifdef SC_HAS_SENDER
if (const auto last = sc::read_last_session(); last.has_value()) {
spawn_detached_sender(last->peer, last->bitrate_kbps);
}
#endif
}
}
print_waybar_status();
return 0;
}
int run_receiver(const sc::ReceiveCommand& command) {
sc::ReceiverPipelineConfig config;
config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast<std::uint16_t>(command.local_rtp_port)};
config.signaling_port = static_cast<std::uint16_t>(command.signaling_port);
config.decoder.hardware_accel = !command.software_decode;
// Fullscreen is automatic under KMSDRM (headless); the flag forces it
// on desktop sessions.
config.renderer.fullscreen = command.fullscreen;
@@ -323,7 +305,7 @@ int run_discover(const sc::DiscoverCommand& command) {
}
for (auto& [key, hosts] : receivers) {
std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) {
return address_preference(lhs) < address_preference(rhs);
return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs);
});
std::string joined =
std::accumulate(hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) {
@@ -357,5 +339,8 @@ int main(int argc, char* argv[]) {
if (const sc::ReceiveCommand* receive = std::get_if<sc::ReceiveCommand>(&*command)) {
return run_receiver(*receive);
}
if (const sc::WaybarCommand* waybar = std::get_if<sc::WaybarCommand>(&*command)) {
return run_waybar(*waybar);
}
return run_discover(std::get<sc::DiscoverCommand>(*command));
}
+22 -3
View File
@@ -1,3 +1,23 @@
# Shared application internals used by both the CLI binary and the GUI:
# pipelines, session orchestration, and the state store.
sc_app_core_sources = files(
'pipelines.cpp',
'sender_session.cpp',
'state_store.cpp',
)
sc_app_core = static_library('sc_app_core',
sc_app_core_sources,
include_directories : [sc_core_inc, include_directories('.')],
cpp_args : ['-DSC_HAS_SENDER=1'],
dependencies : [dep_json])
sc_app_core_dep = declare_dependency(
link_with : sc_app_core,
include_directories : include_directories('.'),
dependencies : [dep_json])
# The screencast application binary wiring every module together.
# Receiver-only builds (-Dsender=false) exclude the capture backend and the
# sender pipeline.
@@ -5,10 +25,9 @@
screencast_sources = [
'cli.cpp',
'main.cpp',
'pipelines.cpp',
]
screencast_dependencies = [sc_codec_dep, sc_network_dep, sc_render_dep]
screencast_dependencies = [sc_app_core_dep, sc_codec_dep, sc_network_dep, sc_render_dep]
screencast_arguments = []
if build_sender
@@ -16,7 +35,7 @@ if build_sender
screencast_arguments += ['-DSC_HAS_SENDER=1']
endif
screencast = executable('screencast',
executable('screencast',
screencast_sources,
cpp_args : screencast_arguments,
dependencies : screencast_dependencies,
+71 -3
View File
@@ -1,11 +1,15 @@
#include "screencast/app/pipeline.h"
#include "state_store.h"
#include "screencast/network/discovery.h"
#include "screencast/network/h264_packetizer.h"
#include "screencast/network/signaling.h"
#include <unistd.h>
#include <unistd.h>
#include <array>
#include <chrono>
#include <condition_variable>
@@ -53,10 +57,27 @@ class SenderPipeline::Impl {
transport_->set_peer(config_.peer_rtp_endpoint);
run_thread_ = std::jthread([this](std::stop_token stop_token) { run(std::move(stop_token)); });
// Publish the session for status widgets and one-click restarts.
// The RTP peer is what a restart needs; the session id identifies it.
write_sender_state(SenderState{
.session_id = config_.session_id,
.receiver = std::format("{}:{}", config_.peer_rtp_endpoint.address, config_.peer_rtp_endpoint.port),
.bitrate_kbps = config_.encoder.bitrate_kbps,
.pid = ::getpid(),
.started_epoch_ms = 0});
// A restart targets the receiver's signaling endpoint; the RTP
// endpoint is re-negotiated from it.
std::string restart_peer = std::format("{}:5005", config_.peer_rtp_endpoint.address);
if (config_.signaling_server.has_value()) {
restart_peer = std::format("{}:{}", config_.signaling_server->address, config_.signaling_server->port);
}
write_last_session(LastSession{.peer = restart_peer, .bitrate_kbps = config_.encoder.bitrate_kbps});
return true;
}
void stop() {
remove_sender_state();
if (capture_ != nullptr) {
capture_->stop();
}
@@ -64,9 +85,17 @@ class SenderPipeline::Impl {
transport_->stop();
}
void request_keyframe() {
keyframe_requested_.store(true);
}
private:
void run(std::stop_token stop_token) {
while (!stop_token.stop_requested()) {
if (keyframe_requested_.exchange(false) && encoder_ != nullptr) {
encoder_->request_keyframe();
}
const std::optional<CapturedFrame> frame = capture_->next_frame();
if (!frame.has_value()) {
break;
@@ -109,8 +138,13 @@ class SenderPipeline::Impl {
H264Packetizer packetizer_;
std::unique_ptr<RtpTransport> transport_ = RtpTransportFactory::create();
std::jthread run_thread_;
std::atomic<bool> keyframe_requested_{false};
};
void SenderPipeline::request_keyframe() {
impl_->request_keyframe();
}
SenderPipeline::SenderPipeline(SenderPipelineConfig config) : impl_(std::make_unique<Impl>(std::move(config))) {}
SenderPipeline::~SenderPipeline() = default;
@@ -238,6 +272,7 @@ class ReceiverPipeline::Impl {
signaling_ = nullptr;
discovery_ = nullptr;
transport_->stop();
jitter_.clear();
renderer_ = nullptr;
decoder_ = nullptr;
{
@@ -248,13 +283,24 @@ class ReceiverPipeline::Impl {
private:
void on_packet(RtpPacket packet) {
std::optional<std::vector<std::byte>> access_unit = depacketizer_.depacketize(packet);
if (!access_unit.has_value()) {
// Absorb reordering (Wi-Fi) before the in-order depacketizer, so a
// late packet is not misread as loss.
for (RtpPacket& ordered : jitter_.push(std::move(packet))) {
deliver_packet(ordered);
}
}
void deliver_packet(const RtpPacket& packet) {
const DepacketizeResult result = depacketizer_.depacketize(packet);
if (result.frame_dropped) {
maybe_send_pli();
}
if (!result.access_unit.has_value()) {
return;
}
EncodedFrame encoded;
encoded.data = std::move(*access_unit);
encoded.data = std::move(*result.access_unit);
encoded.rtp_timestamp = packet.header.timestamp;
encoded.is_keyframe = false;
@@ -282,6 +328,7 @@ class ReceiverPipeline::Impl {
if (offer == nullptr) {
return;
}
session_id_ = offer->session_id;
SessionAnswer answer;
answer.session_id = offer->session_id;
// Empty address: the sender targets the address of its signaling
@@ -290,6 +337,23 @@ class ReceiverPipeline::Impl {
signaling_->send(answer);
}
// Ask the sender for a keyframe after a damaged frame, rate-limited so
// sustained loss cannot flood the signaling channel.
void maybe_send_pli() {
if (signaling_ == nullptr || session_id_.empty()) {
return;
}
const auto now = std::chrono::steady_clock::now();
if (now - last_pli_time_ < kPliMinInterval) {
return;
}
last_pli_time_ = now;
SessionPli pli;
pli.session_id = session_id_;
signaling_->send(pli);
std::cerr << std::format("screencast: frame damaged; requesting a keyframe\n");
}
void render_loop(std::stop_token stop_token) {
while (!stop_token.stop_requested()) {
if (!renderer_->poll_events()) {
@@ -315,17 +379,21 @@ class ReceiverPipeline::Impl {
}
static constexpr std::size_t kMaxQueuedFrames = 3;
static constexpr std::chrono::milliseconds kPliMinInterval{500};
ReceiverPipelineConfig config_;
std::unique_ptr<Renderer> renderer_;
std::unique_ptr<Decoder> decoder_;
H264Depacketizer depacketizer_;
RtpJitterBuffer jitter_;
std::unique_ptr<RtpTransport> transport_ = RtpTransportFactory::create();
std::unique_ptr<SignalingChannel> signaling_;
std::unique_ptr<DiscoveryService> discovery_;
std::jthread render_thread_;
std::mutex queue_mutex_;
std::deque<DecodedFrame> queue_;
std::string session_id_;
std::chrono::steady_clock::time_point last_pli_time_{};
bool stream_started_ = false;
bool present_error_logged_ = false;
};
+177
View File
@@ -0,0 +1,177 @@
#include "sender_session.h"
#include "screencast/network/signaling.h"
#include <atomic>
#include <charconv>
#include <chrono>
#include <format>
#include <future>
#include <memory>
#include <string_view>
#include <utility>
namespace sc {
namespace {
bool is_private_ipv4(std::string_view host) {
if (host.rfind("192.168.", 0) == 0 || host.rfind("10.", 0) == 0) {
return true;
}
if (host.rfind("172.", 0) == 0) {
const std::size_t second = host.find('.', 5);
if (second != std::string_view::npos) {
int octet = 0;
const auto [pointer, error] = std::from_chars(host.data() + 5, host.data() + second, octet);
if (error == std::errc{}) {
return octet >= 16 && octet <= 31;
}
}
}
return false;
}
} // namespace
int SenderSession::address_preference(std::string_view host) {
if (host.find(':') == std::string_view::npos) {
return is_private_ipv4(host) ? 0 : 1;
}
if (host.rfind("fd", 0) == 0 || host.rfind("fc", 0) == 0) {
return 2;
}
if (host.rfind("2002:", 0) == 0) {
return 4;
}
if (host.rfind("fe80:", 0) == 0) {
return 5;
}
return 3;
}
std::variant<SenderSession, std::string>
SenderSession::start(const Endpoint& signaling_endpoint, int bitrate_kbps, CaptureTarget target) {
auto channel_result = SignalingFactory::create_client();
if (is_network_error(channel_result)) {
return network_error(channel_result).message;
}
auto channel = std::move(network_value(channel_result));
if (!channel->connect(signaling_endpoint)) {
return std::format(
"failed to connect to the receiver at {}:{}", signaling_endpoint.address, signaling_endpoint.port);
}
// Offer + answer.
static std::atomic<std::uint32_t> session_counter{0};
const std::string session_id = std::format(
"sc-{:x}-{:x}",
static_cast<std::uint32_t>(std::chrono::steady_clock::now().time_since_epoch().count()) & 0xffffffffU,
session_counter.fetch_add(1));
std::promise<SessionAnswer> answer_promise;
auto answer_future = answer_promise.get_future();
std::atomic<bool> answered{false};
channel->on_message([&](const SignalingMessage& message) {
if (const SessionAnswer* answer = std::get_if<SessionAnswer>(&message)) {
if (!answered.exchange(true)) {
answer_promise.set_value(*answer);
}
}
});
SessionOffer offer;
offer.session_id = session_id;
offer.codec_name = "h264";
offer.frame_rate_num = 25;
offer.frame_rate_den = 1;
channel->send(offer);
if (answer_future.wait_for(std::chrono::seconds(5)) != std::future_status::ready) {
channel->disconnect();
return std::string{"the receiver did not answer the session offer"};
}
const SessionAnswer answer = answer_future.get();
if (answer.session_id != session_id) {
channel->disconnect();
return std::string{"session mismatch in the receiver's answer"};
}
if (answer.rtp_endpoint.port == 0) {
channel->disconnect();
return std::string{"the receiver did not provide an RTP port"};
}
// Stream to the negotiated endpoint; an empty address means "the
// address you reached me on".
const Endpoint rtp_endpoint = answer.rtp_endpoint.address.empty()
? Endpoint{signaling_endpoint.address, answer.rtp_endpoint.port}
: answer.rtp_endpoint;
SenderPipelineConfig config;
config.capture_target = target;
config.peer_rtp_endpoint = rtp_endpoint;
config.signaling_server = signaling_endpoint;
config.encoder.bitrate_kbps = bitrate_kbps;
config.session_id = session_id;
auto pipeline = std::make_unique<SenderPipeline>(std::move(config));
if (!pipeline->start()) {
channel->disconnect();
return std::string{"failed to start the sender pipeline"};
}
// PLI feedback over the still-open channel.
channel->on_message([pipeline = pipeline.get(), session_id](const SignalingMessage& message) {
const SessionPli* pli = std::get_if<SessionPli>(&message);
if (pli != nullptr && pli->session_id == session_id) {
pipeline->request_keyframe();
}
});
SenderSession session;
session.channel_ = std::move(channel);
session.pipeline_ = std::move(pipeline);
session.session_id_ = session_id;
session.receiver_ = std::format("{}:{}", rtp_endpoint.address, rtp_endpoint.port);
return session;
}
SenderSession::SenderSession(SenderSession&& other) noexcept
: channel_(std::move(other.channel_)),
pipeline_(std::move(other.pipeline_)),
session_id_(std::move(other.session_id_)),
receiver_(std::move(other.receiver_)),
stopped_(other.stopped_) {}
SenderSession& SenderSession::operator=(SenderSession&& other) noexcept {
stop();
channel_ = std::move(other.channel_);
pipeline_ = std::move(other.pipeline_);
session_id_ = std::move(other.session_id_);
receiver_ = std::move(other.receiver_);
stopped_ = other.stopped_;
return *this;
}
SenderSession::~SenderSession() {
stop();
}
void SenderSession::stop() {
if (stopped_) {
return;
}
stopped_ = true;
// The channel first: disconnecting joins its reader threads, so no PLI
// callback can race the pipeline teardown it points at.
if (channel_ != nullptr) {
channel_->disconnect();
channel_ = nullptr;
}
if (pipeline_ != nullptr) {
pipeline_->stop();
pipeline_ = nullptr;
}
}
} // namespace sc
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "screencast/app/pipeline.h"
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <variant>
namespace sc {
// A running sender session: it negotiated over signaling (keeping the
// channel open for PLI feedback) and drives the sender pipeline. Shared by
// the CLI, the GUI, and the waybar widget's one-click restart.
class SenderSession {
public:
// Blocking: connects, offers, waits for the answer, and starts the
// pipeline — which includes the portal's interactive source picker.
// Returns an error message on failure.
static std::variant<SenderSession, std::string>
start(const Endpoint& signaling_endpoint, int bitrate_kbps, CaptureTarget target);
// Ordering for trying a receiver's addresses: private IPv4 first (LANs,
// most reliable), then public IPv4, ULA, and global IPv6. 6to4 (2002::)
// and link-local addresses last: 6to4 is frequently unreachable between
// LAN peers, and link-local needs a scope id to even route.
static int address_preference(std::string_view host);
SenderSession() = default;
~SenderSession();
SenderSession(SenderSession&& other) noexcept;
SenderSession& operator=(SenderSession&& other) noexcept;
// Graceful stop: withdraws state and closes the signaling channel.
void stop();
[[nodiscard]] const std::string& session_id() const {
return session_id_;
}
[[nodiscard]] const std::string& receiver() const {
return receiver_;
}
private:
std::unique_ptr<class SignalingChannel> channel_;
std::unique_ptr<SenderPipeline> pipeline_;
std::string session_id_;
std::string receiver_;
bool stopped_ = false;
};
} // namespace sc
+140
View File
@@ -0,0 +1,140 @@
#include "state_store.h"
#include <nlohmann/json.hpp>
#include <sys/types.h>
#include <unistd.h>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
namespace sc {
namespace {
namespace fs = std::filesystem;
fs::path runtime_dir() {
const char* xdg = std::getenv("XDG_RUNTIME_DIR");
if (xdg != nullptr && *xdg != '\0') {
return fs::path{xdg} / "screencast";
}
return fs::temp_directory_path() / ("screencast-" + std::to_string(::getuid()));
}
fs::path config_dir() {
const char* xdg = std::getenv("XDG_CONFIG_HOME");
if (xdg != nullptr && *xdg != '\0') {
return fs::path{xdg} / "screencast";
}
const char* home = std::getenv("HOME");
return fs::path{home != nullptr ? home : "."} / ".config" / "screencast";
}
std::optional<std::string> read_file(const fs::path& path) {
std::ifstream file{path, std::ios::binary};
if (!file.is_open()) {
return std::nullopt;
}
std::ostringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
bool write_file(const fs::path& path, std::string_view contents) {
std::error_code error;
fs::create_directories(path.parent_path(), error);
std::ofstream file{path, std::ios::binary | std::ios::trunc};
if (!file.is_open()) {
return false;
}
file.write(contents.data(), static_cast<std::streamsize>(contents.size()));
return file.good();
}
std::int64_t epoch_ms() {
const auto now = std::chrono::system_clock::now().time_since_epoch();
return std::chrono::duration_cast<std::chrono::milliseconds>(now).count();
}
bool process_alive(int pid) {
if (pid <= 0) {
return false;
}
return ::kill(static_cast<pid_t>(pid), 0) == 0;
}
} // namespace
bool write_sender_state(const SenderState& state) {
nlohmann::json json;
json["session_id"] = state.session_id;
json["receiver"] = state.receiver;
json["bitrate_kbps"] = state.bitrate_kbps;
json["pid"] = state.pid;
json["started_epoch_ms"] = state.started_epoch_ms != 0 ? state.started_epoch_ms : epoch_ms();
return write_file(runtime_dir() / "sender.json", json.dump() + "\n");
}
bool remove_sender_state() {
std::error_code error;
const bool removed = fs::remove(runtime_dir() / "sender.json", error);
return removed || !fs::exists(runtime_dir() / "sender.json");
}
std::optional<SenderState> read_sender_state() {
const std::optional<std::string> contents = read_file(runtime_dir() / "sender.json");
if (!contents.has_value()) {
return std::nullopt;
}
const nlohmann::json json = nlohmann::json::parse(*contents, nullptr, /*allow_exceptions=*/false);
if (json.is_discarded() || !json.is_object()) {
return std::nullopt;
}
SenderState state;
state.session_id = json.value("session_id", std::string{});
state.receiver = json.value("receiver", std::string{});
state.bitrate_kbps = json.value("bitrate_kbps", 0);
state.pid = json.value("pid", 0);
state.started_epoch_ms = json.value("started_epoch_ms", std::int64_t{0});
// A state file without a live process is a crash remnant: not streaming.
if (!process_alive(state.pid)) {
(void)remove_sender_state();
return std::nullopt;
}
return state;
}
void write_last_session(const LastSession& last) {
nlohmann::json json;
json["peer"] = last.peer;
json["bitrate_kbps"] = last.bitrate_kbps;
(void)write_file(config_dir() / "last-session.json", json.dump() + "\n");
}
std::optional<LastSession> read_last_session() {
const std::optional<std::string> contents = read_file(config_dir() / "last-session.json");
if (!contents.has_value()) {
return std::nullopt;
}
const nlohmann::json json = nlohmann::json::parse(*contents, nullptr, /*allow_exceptions=*/false);
if (json.is_discarded() || !json.is_object()) {
return std::nullopt;
}
LastSession last;
last.peer = json.value("peer", std::string{});
last.bitrate_kbps = json.value("bitrate_kbps", 0);
if (last.peer.empty() || last.bitrate_kbps <= 0) {
return std::nullopt;
}
return last;
}
} // namespace sc
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
namespace sc {
// Live state of a running sender, written by the pipeline so status
// widgets (waybar) and one-click restarts work for every front-end.
struct SenderState {
std::string session_id;
std::string receiver; // "host:port" of the RTP endpoint
int bitrate_kbps = 0;
int pid = 0;
std::int64_t started_epoch_ms = 0;
};
// The last successfully started session, persisted across reboots so a
// single click can restart streaming without picking anything.
struct LastSession {
std::string peer; // "host" or "host:port" for the signaling endpoint
int bitrate_kbps = 0;
};
bool write_sender_state(const SenderState& state);
bool remove_sender_state();
// Returns nullopt when no sender is running (a stale state file is
// treated as not streaming and is cleaned up).
std::optional<SenderState> read_sender_state();
void write_last_session(const LastSession& last);
std::optional<LastSession> read_last_session();
} // namespace sc
+47 -12
View File
@@ -5,6 +5,8 @@
#include <array>
#include <cstdint>
#include <cstring>
#include <format>
#include <iostream>
#include <limits>
#include <optional>
#include <string>
@@ -199,19 +201,18 @@ class FfmpegDecoder final : public Decoder {
mutable AVPixelFormat scaler_input_format_ = AV_PIX_FMT_NONE;
};
CodecResult<std::unique_ptr<Decoder>> DecoderFactory::create(const DecoderConfig& config) {
if (config.codec_name != "h264") {
return CodecError{"only h264 is supported in phase 2"};
}
namespace {
const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_H264);
// Opens the given decoder implementation for the given config. Returns an
// error string on failure (used for the hardware probe + fallback).
std::optional<std::string> open_decoder(const DecoderConfig& config, const AVCodec* codec, AvCodecContextPtr& ctx) {
if (codec == nullptr) {
return CodecError{"h264 decoder not found"};
return std::string{"decoder not found"};
}
AvCodecContextPtr ctx(avcodec_alloc_context3(codec), AvCodecContextDeleter{});
ctx.reset(avcodec_alloc_context3(codec));
if (ctx == nullptr) {
return CodecError{"failed to allocate decoder context"};
return std::string{"failed to allocate decoder context"};
}
ctx->codec_type = AVMEDIA_TYPE_VIDEO;
@@ -225,24 +226,58 @@ CodecResult<std::unique_ptr<Decoder>> DecoderFactory::create(const DecoderConfig
if (!config.extradata.empty()) {
if (config.extradata.size() > static_cast<std::size_t>(std::numeric_limits<int>::max())) {
return CodecError{"decoder extradata is too large"};
return std::string{"decoder extradata is too large"};
}
// FFmpeg bitstream parsers may read past the end of extradata, so the
// buffer must include the padding they require.
ctx->extradata = static_cast<uint8_t*>(av_malloc(config.extradata.size() + AV_INPUT_BUFFER_PADDING_SIZE));
if (ctx->extradata == nullptr) {
return CodecError{"failed to allocate decoder extradata"};
return std::string{"failed to allocate decoder extradata"};
}
std::memcpy(ctx->extradata, config.extradata.data(), config.extradata.size());
std::memset(ctx->extradata + config.extradata.size(), 0, AV_INPUT_BUFFER_PADDING_SIZE);
ctx->extradata_size = static_cast<int>(config.extradata.size());
}
int open_ret = avcodec_open2(ctx.get(), codec, nullptr);
const int open_ret = avcodec_open2(ctx.get(), codec, nullptr);
if (open_ret < 0) {
return CodecError{std::string{"failed to open h264 decoder: "} + ffmpeg_error(open_ret)};
return std::string{"failed to open decoder: "} + ffmpeg_error(open_ret);
}
return std::nullopt;
}
} // namespace
CodecResult<std::unique_ptr<Decoder>> DecoderFactory::create(const DecoderConfig& config) {
if (config.codec_name != "h264") {
return CodecError{"only h264 is supported"};
}
// Hardware first (v4l2 mem2mem: the Pi's VideoCore H.264 decoder), with
// an automatic software fallback. The kernel's bitstream parser reads
// the stream dimensions from the in-band SPS, so they are not required
// up front.
if (config.hardware_accel) {
if (const AVCodec* hw = avcodec_find_decoder_by_name("h264_v4l2m2m"); hw != nullptr) {
AvCodecContextPtr ctx{nullptr, AvCodecContextDeleter{}};
if (auto error = open_decoder(config, hw, ctx)) {
std::cerr << std::format("screencast: hardware decode unavailable ({}); falling back to "
"software\n",
*error);
} else {
std::cerr << "screencast: using hardware H.264 decode (h264_v4l2m2m)\n";
return std::make_unique<FfmpegDecoder>(std::move(ctx), config);
}
} else {
std::cerr << "screencast: hardware decoder not compiled in; using software decode\n";
}
}
const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_H264);
AvCodecContextPtr ctx{nullptr, AvCodecContextDeleter{}};
if (auto error = open_decoder(config, codec, ctx)) {
return CodecError{std::move(*error)};
}
return std::make_unique<FfmpegDecoder>(std::move(ctx), config);
}
+309
View File
@@ -0,0 +1,309 @@
// GTK4 sender panel: discover receivers on the LAN, pick one, set the
// bitrate, and start/stop streaming. The blocking parts (portal source
// picker, negotiation) run on worker threads so the UI stays responsive.
#include "screencast/network/discovery.h"
#include "sender_session.h"
#include "state_store.h"
#include <gtkmm.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace {
using sc::DiscoveredPeer;
using sc::Endpoint;
struct ReceiverRow {
std::string name;
std::string host;
std::uint16_t signaling_port = 0;
};
class SenderWindow : public Gtk::ApplicationWindow {
public:
SenderWindow() {
set_title("screencast");
set_default_size(440, 400);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 8);
set_child(*box);
box->set_margin(12);
// Receiver list header + refresh.
auto* header = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
box->append(*header);
auto* title = Gtk::make_managed<Gtk::Label>();
title->set_text("Receivers");
title->set_hexpand(true);
title->set_halign(Gtk::Align::START);
header->append(*title);
refresh_button_ = Gtk::make_managed<Gtk::Button>();
refresh_button_->set_label("Refresh");
refresh_button_->signal_clicked().connect(sigc::mem_fun(*this, &SenderWindow::on_refresh));
header->append(*refresh_button_);
scrolled_ = Gtk::make_managed<Gtk::ScrolledWindow>();
scrolled_->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scrolled_->set_vexpand(true);
box->append(*scrolled_);
receiver_list_ = Gtk::make_managed<Gtk::ListBox>();
receiver_list_->set_selection_mode(Gtk::SelectionMode::SINGLE);
receiver_list_->signal_row_selected().connect(
[this](Gtk::ListBoxRow*) { Glib::signal_idle().connect_once([this] { update_sensitivity(); }); });
scrolled_->set_child(*receiver_list_);
// Bitrate.
auto* bitrate_box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
box->append(*bitrate_box);
bitrate_label_ = Gtk::make_managed<Gtk::Label>();
bitrate_label_->set_hexpand(true);
bitrate_label_->set_halign(Gtk::Align::START);
bitrate_box->append(*bitrate_label_);
bitrate_scale_ = Gtk::make_managed<Gtk::Scale>(Gtk::Orientation::HORIZONTAL);
bitrate_scale_->set_range(500.0, 20000.0);
bitrate_scale_->set_value(4000.0);
bitrate_scale_->set_increments(500.0, 1000.0);
bitrate_scale_->set_draw_value(false);
bitrate_scale_->set_hexpand(true);
bitrate_scale_->signal_value_changed().connect([this] { update_bitrate_label(); });
bitrate_box->append(*bitrate_scale_);
update_bitrate_label();
start_button_ = Gtk::make_managed<Gtk::Button>();
start_button_->set_label("Start");
start_button_->signal_clicked().connect(sigc::mem_fun(*this, &SenderWindow::on_start_stop));
box->append(*start_button_);
status_label_ = Gtk::make_managed<Gtk::Label>();
status_label_->set_wrap(true);
status_label_->set_halign(Gtk::Align::START);
status_label_->set_valign(Gtk::Align::START);
status_label_->set_vexpand(true);
box->append(*status_label_);
// Per-second status refresh (elapsed time, liveness heartbeat).
Glib::signal_timeout().connect_seconds(
[this]() -> bool {
update_status();
return true;
},
1);
update_sensitivity();
on_refresh();
}
~SenderWindow() override {
if (worker_.joinable()) {
worker_.join();
}
}
private:
void update_bitrate_label() {
bitrate_label_->set_text(std::format("Bitrate: {} kbps", current_bitrate()));
}
int current_bitrate() const {
return static_cast<int>(bitrate_scale_->get_value());
}
void clear_receiver_rows() {
for (Gtk::Widget* row : receiver_rows_) {
receiver_list_->remove(*row);
}
receiver_rows_.clear();
receivers_.clear();
}
void on_refresh() {
refresh_button_->set_sensitive(false);
status("discovering receivers…");
clear_receiver_rows();
worker_ = std::jthread([this](std::stop_token stop_token) {
std::vector<DiscoveredPeer> peers;
std::mutex mutex;
std::string error;
auto discovery_result = sc::DiscoveryFactory::create_avahi();
if (sc::is_network_error(discovery_result)) {
error = sc::network_error(discovery_result).message;
} else {
auto discovery = std::move(sc::network_value(discovery_result));
if (!discovery->browse([&](const DiscoveredPeer& peer) {
std::lock_guard lock(mutex);
const bool known = std::any_of(peers.begin(), peers.end(), [&](const DiscoveredPeer& existing) {
return existing.service_name == peer.service_name &&
existing.signaling_port == peer.signaling_port;
});
if (!known) {
peers.push_back(peer);
}
})) {
error = discovery->last_error();
}
if (!stop_token.stop_requested()) {
std::this_thread::sleep_for(std::chrono::seconds(3));
}
discovery->stop();
}
Glib::signal_idle().connect_once([this, peers = std::move(peers), error = std::move(error)]() mutable {
receivers_ = group_receivers(std::move(peers));
for (const ReceiverRow& row : receivers_) {
auto* label = Gtk::make_managed<Gtk::Label>();
label->set_text(std::format("{}\n{}", row.name, row.host));
label->set_halign(Gtk::Align::START);
receiver_list_->append(*label);
receiver_rows_.push_back(label);
}
refresh_button_->set_sensitive(true);
update_sensitivity();
status(error.empty() ? (receivers_.empty() ? "no receivers found"
: std::format("{} receiver(s)", receivers_.size()))
: "discovery failed: " + error);
});
});
}
static std::vector<ReceiverRow> group_receivers(std::vector<DiscoveredPeer> peers) {
std::map<std::pair<std::string, std::uint16_t>, std::vector<std::string>> grouped;
for (DiscoveredPeer& peer : peers) {
grouped[{peer.service_name, peer.signaling_port}].push_back(std::move(peer.host));
}
std::vector<ReceiverRow> rows;
for (auto& [key, hosts] : grouped) {
std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) {
return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs);
});
ReceiverRow row;
row.name = key.first;
row.host = std::move(hosts.front());
row.signaling_port = key.second;
rows.push_back(std::move(row));
}
return rows;
}
void on_start_stop() {
if (session_.has_value()) {
auto session = std::move(*session_);
session_ = std::nullopt;
start_button_->set_sensitive(false);
status("stopping…");
worker_ = std::jthread([this, session = std::move(session)](std::stop_token) mutable {
session.stop();
Glib::signal_idle().connect_once([this] {
start_button_->set_label("Start");
update_sensitivity();
status("idle");
});
});
return;
}
const int index = selected_index();
if (index < 0) {
status("pick a receiver first");
return;
}
const ReceiverRow& receiver = receivers_[static_cast<std::size_t>(index)];
const Endpoint signaling{receiver.host, receiver.signaling_port};
const int bitrate = current_bitrate();
start_button_->set_sensitive(false);
status(std::format("connecting to {}… (choose a source in the portal dialog)", receiver.name));
worker_ = std::jthread([this, signaling, bitrate](std::stop_token) {
auto result = sc::SenderSession::start(signaling, bitrate, sc::CaptureTargetWholeScreen{});
if (auto* error = std::get_if<std::string>(&result)) {
Glib::signal_idle().connect_once([this, message = *error] {
start_button_->set_label("Start");
update_sensitivity();
status("failed: " + message);
});
return;
}
// sigc++ slots require copyable lambdas; the move-only session
// travels via shared_ptr.
auto session = std::make_shared<sc::SenderSession>(std::move(std::get<sc::SenderSession>(result)));
Glib::signal_idle().connect_once([this, session] {
const std::string receiver_text = session->receiver();
const std::string session_id = session->session_id();
session_ = std::move(*session);
start_button_->set_label("Stop");
update_sensitivity();
status(std::format("streaming to {} (session {})", receiver_text, session_id));
});
});
}
int selected_index() const {
Gtk::ListBoxRow* row = receiver_list_->get_selected_row();
return row != nullptr ? row->get_index() : -1;
}
void update_sensitivity() {
if (session_.has_value()) {
start_button_->set_sensitive(true);
refresh_button_->set_sensitive(false);
return;
}
refresh_button_->set_sensitive(true);
start_button_->set_sensitive(selected_index() >= 0);
}
void update_status() {
if (!session_.has_value()) {
return;
}
const auto state = sc::read_sender_state();
if (state.has_value()) {
const std::int64_t elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count() -
state->started_epoch_ms;
status(std::format("streaming to {}\nsession {}\nelapsed {}m {:02}s",
state->receiver,
state->session_id,
elapsed_ms / 60000,
(elapsed_ms / 1000) % 60));
}
}
void status(const std::string& text) {
status_label_->set_text(text);
}
std::vector<ReceiverRow> receivers_;
std::vector<Gtk::Widget*> receiver_rows_;
std::optional<sc::SenderSession> session_;
std::jthread worker_;
Gtk::Button* refresh_button_ = nullptr;
Gtk::ScrolledWindow* scrolled_ = nullptr;
Gtk::ListBox* receiver_list_ = nullptr;
Gtk::Scale* bitrate_scale_ = nullptr;
Gtk::Label* bitrate_label_ = nullptr;
Gtk::Button* start_button_ = nullptr;
Gtk::Label* status_label_ = nullptr;
};
} // namespace
int main(int argc, char** argv) {
auto app = Gtk::Application::create("io.github.screen_cast.panel");
return app->make_window_and_run<SenderWindow>(argc, argv);
}
+15
View File
@@ -0,0 +1,15 @@
# GTK4 sender panel, behind the `gui` option (default: false). The core
# CLI and tests never need GTK.
dep_gtkmm = dependency('gtkmm-4.0')
screencast_gui_sources = files(
'gui.cpp',
)
executable('screencast-gui',
screencast_gui_sources,
include_directories : [sc_core_inc, include_directories('../app')],
dependencies : [dep_gtkmm, sc_app_core_dep, sc_capture_dep, sc_codec_dep, sc_network_dep,
sc_render_dep],
install : true)
+4
View File
@@ -32,3 +32,7 @@ subdir('network')
subdir('render')
subdir('app')
if get_option('gui')
subdir('gui')
endif
+9 -4
View File
@@ -139,7 +139,9 @@ void H264Packetizer::append_fu_a_packets(std::span<const std::byte> nal,
}
}
std::optional<std::vector<std::byte>> H264Depacketizer::depacketize(const RtpPacket& packet) {
DepacketizeResult H264Depacketizer::depacketize(const RtpPacket& packet) {
DepacketizeResult result;
// Track sequence continuity: a gap means packets were lost.
if (last_sequence_number_.has_value()) {
const std::uint16_t expected = static_cast<std::uint16_t>(*last_sequence_number_ + 1);
@@ -157,6 +159,7 @@ std::optional<std::vector<std::byte>> H264Depacketizer::depacketize(const RtpPac
// lost its tail and can no longer be recovered.
if (frame_started_ && packet.header.timestamp != frame_timestamp_) {
drop_frame();
result.frame_dropped = true;
}
if (!frame_started_) {
frame_started_ = true;
@@ -216,7 +219,7 @@ std::optional<std::vector<std::byte>> H264Depacketizer::depacketize(const RtpPac
}
if (!packet.header.marker) {
return std::nullopt;
return result;
}
if (fu_active_) {
@@ -226,9 +229,11 @@ std::optional<std::vector<std::byte>> H264Depacketizer::depacketize(const RtpPac
fu_nal_.clear();
}
std::optional<std::vector<std::byte>> result;
if (!frame_damaged_ && !access_unit_.empty()) {
result = std::move(access_unit_);
result.access_unit = std::move(access_unit_);
} else {
// The frame that just ended is unusable.
result.frame_dropped = true;
}
drop_frame();
return result;
+55
View File
@@ -133,4 +133,59 @@ std::optional<RtpPacket> RtpPacket::parse(std::span<const std::byte> in) noexcep
return packet;
}
RtpJitterBuffer::RtpJitterBuffer(std::size_t max_depth, std::chrono::milliseconds max_delay)
: max_depth_(max_depth), max_delay_(max_delay) {}
std::vector<RtpPacket> RtpJitterBuffer::push(RtpPacket packet) {
std::vector<RtpPacket> released;
const std::uint16_t sequence = packet.header.sequence_number;
const auto now = std::chrono::steady_clock::now();
std::lock_guard lock(mutex_);
if (!next_expected_.has_value()) {
next_expected_ = sequence;
}
// Serial-number comparison: a difference >= 32768 means the packet is
// older than what we already delivered (a duplicate or a late straggler).
const std::uint16_t distance = static_cast<std::uint16_t>(sequence - *next_expected_);
if (distance >= 32768) {
return released; // discard the straggler
}
buffer_[sequence] = {now, std::move(packet)};
// Release the consecutive run from the expected sequence.
while (true) {
const auto entry = buffer_.find(*next_expected_);
if (entry == buffer_.end()) {
break;
}
released.push_back(std::move(entry->second.second));
buffer_.erase(entry);
++(*next_expected_);
}
// A missing packet stalls the run: age out the backlog (or bound the
// buffer) and release what is there in order, so genuine loss reaches
// the depacketizer's gap detection rather than blocking forever.
if (!buffer_.empty()) {
const auto head_age = now - buffer_.begin()->second.first;
if (head_age > max_delay_ || buffer_.size() > max_depth_) {
for (auto& entry : buffer_) {
released.push_back(std::move(entry.second.second));
}
next_expected_ = static_cast<std::uint16_t>(buffer_.rbegin()->first + 1);
buffer_.clear();
}
}
return released;
}
void RtpJitterBuffer::clear() {
std::lock_guard lock(mutex_);
buffer_.clear();
next_expected_.reset();
}
} // namespace sc
+13 -5
View File
@@ -115,12 +115,15 @@ std::string serialize_message(const SignalingMessage& message) {
json["frame_rate_den"] = offer->frame_rate_den;
json["rtp_address"] = offer->rtp_endpoint.address;
json["rtp_port"] = offer->rtp_endpoint.port;
} else {
const SessionAnswer& answer = std::get<SessionAnswer>(message);
} else if (const SessionAnswer* answer = std::get_if<SessionAnswer>(&message)) {
json["type"] = "answer";
json["session_id"] = answer.session_id;
json["rtp_address"] = answer.rtp_endpoint.address;
json["rtp_port"] = answer.rtp_endpoint.port;
json["session_id"] = answer->session_id;
json["rtp_address"] = answer->rtp_endpoint.address;
json["rtp_port"] = answer->rtp_endpoint.port;
} else {
const SessionPli& pli = std::get<SessionPli>(message);
json["type"] = "pli";
json["session_id"] = pli.session_id;
}
return json.dump() + "\n";
}
@@ -171,6 +174,11 @@ std::optional<SignalingMessage> parse_message(std::string_view line) {
answer.rtp_endpoint = rtp_endpoint;
return answer;
}
if (type == "pli") {
SessionPli pli;
pli.session_id = session_id;
return pli;
}
return std::nullopt;
}
+3 -3
View File
@@ -74,13 +74,13 @@ struct ReceiverSink {
int last_height = 0;
void on_packet(sc::RtpPacket packet) {
std::optional<std::vector<std::byte>> access_unit = depacketizer.depacketize(packet);
if (!access_unit.has_value()) {
const sc::DepacketizeResult result = depacketizer.depacketize(packet);
if (!result.access_unit.has_value()) {
return;
}
sc::EncodedFrame encoded;
encoded.data = std::move(*access_unit);
encoded.data = std::move(*result.access_unit);
encoded.rtp_timestamp = packet.header.timestamp;
auto decoded_result = decoder->decode(encoded);
+85 -8
View File
@@ -4,6 +4,7 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
@@ -288,13 +289,17 @@ void test_depacketize_roundtrip() {
sc::H264Depacketizer depacketizer;
std::optional<std::vector<std::byte>> completed;
bool any_dropped = false;
for (const sc::RtpPacket& packet : packets) {
if (auto result = depacketizer.depacketize(packet)) {
const sc::DepacketizeResult result = depacketizer.depacketize(packet);
if (result.access_unit.has_value()) {
check(!completed.has_value(), "only one completion");
completed = std::move(result);
completed = std::move(result.access_unit);
}
any_dropped = any_dropped || result.frame_dropped;
}
check(completed.has_value(), "frame completed");
check(!any_dropped, "no dropped frames in a clean stream");
check(equal_bytes(*completed, access_unit), "access unit round-trip");
}
@@ -305,9 +310,11 @@ void test_depacketizer_drops_gapped_frames() {
check(packets.size() == 3, "gap test packet count");
sc::H264Depacketizer depacketizer;
check(!depacketizer.depacketize(packets[0]).has_value(), "first fu chunk accepted");
check(!depacketizer.depacketize(packets[0]).access_unit.has_value(), "first fu chunk accepted");
// packets[1] is lost in transit; the tail cannot complete the frame.
check(!depacketizer.depacketize(packets[2]).has_value(), "tail after gap dropped");
const sc::DepacketizeResult tail = depacketizer.depacketize(packets[2]);
check(!tail.access_unit.has_value(), "tail after gap dropped");
check(tail.frame_dropped, "drop reported after gap");
}
void test_depacketizer_separate_frames() {
@@ -319,12 +326,79 @@ void test_depacketizer_separate_frames() {
const std::vector<sc::RtpPacket> second = packetizer.packetize(make_frame(f2, 90000));
sc::H264Depacketizer depacketizer;
const std::optional<std::vector<std::byte>> au1 = depacketizer.depacketize(first[0]);
check(au1.has_value() && equal_bytes(*au1, f1), "first frame");
const sc::DepacketizeResult au1 = depacketizer.depacketize(first[0]);
check(au1.access_unit.has_value() && equal_bytes(*au1.access_unit, f1), "first frame");
check(!au1.frame_dropped, "first frame not dropped");
// Same RTP timestamp on purpose: the marker alone separates frames.
const std::optional<std::vector<std::byte>> au2 = depacketizer.depacketize(second[0]);
check(au2.has_value() && equal_bytes(*au2, f2), "second frame with same timestamp");
const sc::DepacketizeResult au2 = depacketizer.depacketize(second[0]);
check(au2.access_unit.has_value() && equal_bytes(*au2.access_unit, f2), "second frame with same timestamp");
check(!au2.frame_dropped, "second frame not dropped");
}
void test_jitter_buffer_in_order() {
sc::RtpJitterBuffer jitter;
sc::RtpPacket packet;
packet.header.sequence_number = 100;
std::vector<sc::RtpPacket> released = jitter.push(packet);
check(released.size() == 1 && released[0].header.sequence_number == 100, "in-order releases immediately");
packet.header.sequence_number = 101;
released = jitter.push(std::move(packet));
check(released.size() == 1 && released[0].header.sequence_number == 101, "next packet releases too");
}
void test_jitter_buffer_reorders() {
sc::RtpJitterBuffer jitter;
sc::RtpPacket first;
first.header.sequence_number = 1;
std::vector<sc::RtpPacket> released = jitter.push(std::move(first));
check(released.size() == 1 && released[0].header.sequence_number == 1, "first packet releases");
// Arrives ahead of its predecessor: held, not delivered.
sc::RtpPacket third;
third.header.sequence_number = 3;
released = jitter.push(std::move(third));
check(released.empty(), "gap holds packets");
sc::RtpPacket second;
second.header.sequence_number = 2;
released = jitter.push(std::move(second));
check(released.size() == 2, "held packets release in order");
check(released[0].header.sequence_number == 2 && released[1].header.sequence_number == 3,
"released in sequence order");
}
void test_jitter_buffer_overflow_and_stragglers() {
// Small depth: a persistent gap overflows the buffer and flushes what
// is there, so genuine loss reaches the depacketizer instead of
// stalling delivery.
sc::RtpJitterBuffer jitter(4, std::chrono::milliseconds{500});
sc::RtpPacket packet;
packet.header.sequence_number = 10;
check(jitter.push(std::move(packet)).size() == 1, "first releases");
std::vector<sc::RtpPacket> released;
for (std::uint16_t sequence = 12; sequence < 17; ++sequence) {
sc::RtpPacket missing;
missing.header.sequence_number = sequence;
for (sc::RtpPacket out : jitter.push(std::move(missing))) {
released.push_back(std::move(out));
}
}
check(released.size() == 5, "overflow flushes the backlog");
for (std::size_t i = 0; i < released.size(); ++i) {
check(released[i].header.sequence_number == 12 + i, "flushed in order");
}
// A straggler older than the delivered sequence is discarded, not
// re-inserted out of order.
sc::RtpPacket straggler;
straggler.header.sequence_number = 11;
check(jitter.push(std::move(straggler)).empty(), "straggler discarded");
}
void test_sequence_wrap() {
@@ -370,6 +444,9 @@ int main() {
test_depacketize_roundtrip();
test_depacketizer_drops_gapped_frames();
test_depacketizer_separate_frames();
test_jitter_buffer_in_order();
test_jitter_buffer_reorders();
test_jitter_buffer_overflow_and_stragglers();
test_sequence_wrap();
test_default_config_randomizes();
test_empty_inputs();
+21
View File
@@ -93,6 +93,27 @@ int main() {
check(answer.session_id == "test-session-0001", "session id echoes");
check(answer.rtp_endpoint.port == advertised_rtp_port, "rtp port in answer");
// PLI: the receiver side asks for a keyframe mid-session; the client
// must receive it with the session id intact.
std::promise<sc::SessionPli> pli_promise;
auto pli_future = pli_promise.get_future();
std::atomic<bool> pli_seen{false};
client->on_message([&](const sc::SignalingMessage& message) {
if (const sc::SessionPli* pli = std::get_if<sc::SessionPli>(&message)) {
if (!pli_seen.exchange(true)) {
pli_promise.set_value(*pli);
}
}
});
sc::SessionPli pli;
pli.session_id = "test-session-0001";
server->send(pli);
check(pli_future.wait_for(std::chrono::seconds(5)) == std::future_status::ready, "pli received");
const sc::SessionPli received_pli = pli_future.get();
check(received_pli.session_id == "test-session-0001", "pli session id echoes");
client->disconnect();
server->disconnect();