Compare commits
4 Commits
41f71fd217
...
eb86905e67
| Author | SHA1 | Date | |
|---|---|---|---|
| eb86905e67 | |||
| 5c39662cc2 | |||
| 74b3f04082 | |||
| 943596da6d |
+27
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -32,3 +32,7 @@ subdir('network')
|
||||
subdir('render')
|
||||
|
||||
subdir('app')
|
||||
|
||||
if get_option('gui')
|
||||
subdir('gui')
|
||||
endif
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user