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.
This commit is contained in:
@@ -65,6 +65,16 @@ 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.
|
||||
- **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.
|
||||
|
||||
+4
-3
@@ -89,9 +89,10 @@ discovered addresses in reachability order (private IPv4 first).
|
||||
- [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).
|
||||
- [ ] GTK4 sender GUI behind `meson -Dgui=true` + a waybar widget
|
||||
(requested; plan approved scope: sender panel, state file,
|
||||
`screencast waybar` subcommand).
|
||||
- [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.
|
||||
|
||||
@@ -134,6 +134,24 @@ 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:
|
||||
|
||||
@@ -25,7 +25,11 @@ 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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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] [--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)) {
|
||||
@@ -136,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;
|
||||
}
|
||||
|
||||
+108
-149
@@ -1,9 +1,15 @@
|
||||
#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 <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
@@ -53,49 +59,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,104 +103,20 @@ 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();
|
||||
// The channel stays open: the receiver sends PLI keyframe requests over
|
||||
// it during the session.
|
||||
|
||||
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()) {
|
||||
channel.disconnect();
|
||||
return 1;
|
||||
}
|
||||
channel.on_message([&](const sc::SignalingMessage& message) {
|
||||
const sc::SessionPli* pli = std::get_if<sc::SessionPli>(&message);
|
||||
if (pli != nullptr && pli->session_id == offer.session_id) {
|
||||
pipeline.request_keyframe();
|
||||
std::cerr << "screencast: receiver requested a keyframe\n";
|
||||
}
|
||||
});
|
||||
while (!g_interrupted.load()) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
pipeline.stop();
|
||||
channel.disconnect();
|
||||
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);
|
||||
return 1;
|
||||
}
|
||||
return negotiate_and_stream(*channel, signaling, command);
|
||||
}
|
||||
|
||||
} else {
|
||||
std::string error;
|
||||
std::vector<sc::DiscoveredPeer> peers = discover_peers(kSenderDiscoveryTimeoutSeconds, error);
|
||||
if (!error.empty()) {
|
||||
@@ -261,7 +140,8 @@ int run_sender(const sc::SendCommand& command) {
|
||||
std::cerr << std::format(
|
||||
"screencast: {} at {}\n",
|
||||
key.first,
|
||||
std::accumulate(hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) {
|
||||
std::accumulate(
|
||||
hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) {
|
||||
return lhs.empty() ? rhs : lhs + ", " + rhs;
|
||||
}));
|
||||
}
|
||||
@@ -272,29 +152,105 @@ int run_sender(const sc::SendCommand& command) {
|
||||
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);
|
||||
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};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
tried += (tried.empty() ? "" : ", ") + host;
|
||||
}
|
||||
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();
|
||||
if (!state.has_value()) {
|
||||
std::cout << std::format("{{\"text\":\"\u23f8\",\"alt\":\"idle\",\"class\":\"idle\","
|
||||
"\"tooltip\":\"screencast idle \u2014 click to stream to the last receiver\n"
|
||||
"right-click: open the panel\"}}\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;
|
||||
std::cout << std::format("{{\"text\":\"\u25b6\",\"alt\":\"streaming\",\"class\":\"streaming\","
|
||||
"\"tooltip\":\"screencast \u2192 {}\\n{} kbps \u00b7 {}m {:02}s\\nsession {}\"}}\n",
|
||||
state->receiver,
|
||||
state->bitrate_kbps,
|
||||
minutes,
|
||||
seconds,
|
||||
state->session_id);
|
||||
}
|
||||
|
||||
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)};
|
||||
@@ -334,7 +290,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) {
|
||||
@@ -368,5 +324,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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user