feat(gui): host + viewer tabs driving the headless child
The GUI now does real work. Host tab: a config form (quality combo, max-viewers, software-encode + single-window toggles) spawns `pixelpass --host --output json …` via re-exec, then a background thread parses the child's JSON events and the window shows live status — ticket with a copy button, viewer count, streaming/waiting state, host_info summary, and host-full refusals. Viewer tab: paste a code, pick mpv/VLC, Connect spawns `pixelpass <ticket> --output json`, and on the connected event the GUI launches the player (reusing interactive::Player). ChildProc (gui/child.rs) owns the child: reads stdout events over a channel, rings the last 60 stderr lines for failure display, and stops via SIGINT (graceful host teardown) with a 2s grace before SIGKILL — Drop ensures closing the window never orphans a live host. Five round-trip tests lock the common::output::Event ↔ ChildEvent wire contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,238 @@
|
|||||||
|
//! Drives a headless `pixelpass` child process for the GUI.
|
||||||
|
//!
|
||||||
|
//! The GUI re-execs this same binary (via [`std::env::current_exe`]) in
|
||||||
|
//! headless mode with `--output json`, then reads the child's JSON event
|
||||||
|
//! stream on a background thread and forwards parsed events over a channel the
|
||||||
|
//! egui app drains each frame. stderr is captured into a small ring so a
|
||||||
|
//! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
use nix::sys::signal::{Signal, kill};
|
||||||
|
use nix::unistd::Pid;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// One parsed event from the child's stdout. Owned mirror of
|
||||||
|
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so
|
||||||
|
/// the wire format and the parser can evolve independently.
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(tag = "event", rename_all = "snake_case")]
|
||||||
|
pub enum ChildEvent {
|
||||||
|
Ticket {
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
HostInfo {
|
||||||
|
display_server: String,
|
||||||
|
capture: String,
|
||||||
|
quality: String,
|
||||||
|
dimensions: String,
|
||||||
|
hw_encode: bool,
|
||||||
|
max_viewers: u32,
|
||||||
|
max_viewers_source: String,
|
||||||
|
},
|
||||||
|
ViewerCount {
|
||||||
|
active: u32,
|
||||||
|
max: u32,
|
||||||
|
},
|
||||||
|
Capture {
|
||||||
|
state: CaptureState,
|
||||||
|
},
|
||||||
|
ViewerRefused {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
Connected {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CaptureState {
|
||||||
|
Started,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STDERR_TAIL_MAX: usize = 60;
|
||||||
|
|
||||||
|
pub struct ChildProc {
|
||||||
|
child: Child,
|
||||||
|
pub rx: Receiver<ChildEvent>,
|
||||||
|
stderr_tail: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChildProc {
|
||||||
|
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. `ctx`
|
||||||
|
/// is repainted whenever an event arrives so the UI updates live.
|
||||||
|
pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result<Self> {
|
||||||
|
let exe = std::env::current_exe()?;
|
||||||
|
let mut child = Command::new(exe)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let stdout = child.stdout.take().expect("stdout piped");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(stdout);
|
||||||
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ignore any non-event line rather than dropping the stream.
|
||||||
|
if let Ok(ev) = serde_json::from_str::<ChildEvent>(&line) {
|
||||||
|
if tx.send(ev).is_err() {
|
||||||
|
break; // app gone
|
||||||
|
}
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
|
let stderr = child.stderr.take().expect("stderr piped");
|
||||||
|
let tail = stderr_tail.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
|
let mut t = tail.lock().unwrap();
|
||||||
|
t.push(line);
|
||||||
|
let overflow = t.len().saturating_sub(STDERR_TAIL_MAX);
|
||||||
|
if overflow > 0 {
|
||||||
|
t.drain(0..overflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
child,
|
||||||
|
rx,
|
||||||
|
stderr_tail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the child is still running.
|
||||||
|
pub fn is_alive(&mut self) -> bool {
|
||||||
|
matches!(self.child.try_wait(), Ok(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last captured stderr lines, joined — for error display.
|
||||||
|
pub fn stderr_tail(&self) -> String {
|
||||||
|
self.stderr_tail.lock().unwrap().join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gracefully stop the child: SIGINT (so the host runs its ctrl-c teardown
|
||||||
|
/// — tears down capture, closes the endpoint), with a ~2 s grace period
|
||||||
|
/// before a hard kill. Idempotent.
|
||||||
|
pub fn stop(&mut self) {
|
||||||
|
if matches!(self.child.try_wait(), Ok(Some(_))) {
|
||||||
|
return; // already exited
|
||||||
|
}
|
||||||
|
let _ = kill(Pid::from_raw(self.child.id() as i32), Signal::SIGINT);
|
||||||
|
for _ in 0..40 {
|
||||||
|
if matches!(self.child.try_wait(), Ok(Some(_))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ChildProc {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Closing the window (dropping the app, hence the session) must not
|
||||||
|
// orphan a live host child streaming to viewers.
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::common::output::{CaptureState as EmitState, Event};
|
||||||
|
|
||||||
|
// The GUI parses what the headless child emits. These round-trip the
|
||||||
|
// emitter's own types (`common::output::Event`) through the parser
|
||||||
|
// (`ChildEvent`) so a rename on either side of the wire fails here rather
|
||||||
|
// than silently breaking the GUI at runtime.
|
||||||
|
fn parse(emit: Event) -> ChildEvent {
|
||||||
|
let line = serde_json::to_string(&emit).unwrap();
|
||||||
|
serde_json::from_str::<ChildEvent>(&line).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_round_trips() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::Ticket { value: "endpointXYZ" }),
|
||||||
|
ChildEvent::Ticket { value } if value == "endpointXYZ"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_info_round_trips() {
|
||||||
|
let ev = parse(Event::HostInfo {
|
||||||
|
display_server: "Wayland",
|
||||||
|
capture: "fullscreen + system-audio",
|
||||||
|
quality: "Medium",
|
||||||
|
dimensions: "≤720p / 2500 kbps / 30 fps",
|
||||||
|
hw_encode: true,
|
||||||
|
max_viewers: 3,
|
||||||
|
max_viewers_source: "user-specified",
|
||||||
|
});
|
||||||
|
match ev {
|
||||||
|
ChildEvent::HostInfo {
|
||||||
|
display_server,
|
||||||
|
quality,
|
||||||
|
hw_encode,
|
||||||
|
max_viewers,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(display_server, "Wayland");
|
||||||
|
assert_eq!(quality, "Medium");
|
||||||
|
assert!(hw_encode);
|
||||||
|
assert_eq!(max_viewers, 3);
|
||||||
|
}
|
||||||
|
other => panic!("expected HostInfo, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewer_count_round_trips() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::ViewerCount { active: 2, max: 4 }),
|
||||||
|
ChildEvent::ViewerCount { active: 2, max: 4 }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_state_round_trips() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::Capture { state: EmitState::Started }),
|
||||||
|
ChildEvent::Capture { state: CaptureState::Started }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::Capture { state: EmitState::Stopped }),
|
||||||
|
ChildEvent::Capture { state: CaptureState::Stopped }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refused_and_connected_round_trip() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::ViewerRefused { reason: "host is full" }),
|
||||||
|
ChildEvent::ViewerRefused { reason } if reason == "host is full"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::Connected { url: "http://127.0.0.1:5000" }),
|
||||||
|
ChildEvent::Connected { url } if url == "http://127.0.0.1:5000"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+512
-20
@@ -9,15 +9,19 @@
|
|||||||
//! shows. That keeps the fragile capture stack sealed in a separate process:
|
//! shows. That keeps the fragile capture stack sealed in a separate process:
|
||||||
//! the GUI can be closed or crash without taking a live stream down.
|
//! the GUI can be closed or crash without taking a live stream down.
|
||||||
|
|
||||||
|
mod child;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
|
use self::child::{ChildEvent, ChildProc};
|
||||||
|
|
||||||
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the
|
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the
|
||||||
/// main thread (a winit requirement), which is where `main` calls it from.
|
/// main thread (a winit requirement), which is where `main` calls it from.
|
||||||
pub fn run() -> anyhow::Result<()> {
|
pub fn run() -> anyhow::Result<()> {
|
||||||
let options = eframe::NativeOptions {
|
let options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_inner_size([520.0, 420.0])
|
.with_inner_size([520.0, 480.0])
|
||||||
.with_min_inner_size([420.0, 320.0])
|
.with_min_inner_size([460.0, 380.0])
|
||||||
.with_title("PixelPass"),
|
.with_title("PixelPass"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -39,15 +43,138 @@ enum Screen {
|
|||||||
Viewer,
|
Viewer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality`
|
||||||
|
/// argument value passed to the child.
|
||||||
|
#[derive(Default, PartialEq, Clone, Copy)]
|
||||||
|
enum QualitySel {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Source,
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QualitySel {
|
||||||
|
fn as_arg(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
QualitySel::Auto => "auto",
|
||||||
|
QualitySel::Source => "source",
|
||||||
|
QualitySel::High => "high",
|
||||||
|
QualitySel::Medium => "medium",
|
||||||
|
QualitySel::Low => "low",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
QualitySel::Auto => "Auto — pick from my upload speed",
|
||||||
|
QualitySel::Source => "Source — native resolution",
|
||||||
|
QualitySel::High => "High — up to 1080p",
|
||||||
|
QualitySel::Medium => "Medium — up to 720p",
|
||||||
|
QualitySel::Low => "Low — up to 480p",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL: [QualitySel; 5] = [
|
||||||
|
QualitySel::Auto,
|
||||||
|
QualitySel::Source,
|
||||||
|
QualitySel::High,
|
||||||
|
QualitySel::Medium,
|
||||||
|
QualitySel::Low,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Player choices for the viewer screen.
|
||||||
|
#[derive(Default, PartialEq, Clone, Copy)]
|
||||||
|
enum PlayerSel {
|
||||||
|
#[default]
|
||||||
|
Mpv,
|
||||||
|
Vlc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerSel {
|
||||||
|
fn to_player(self) -> crate::interactive::Player {
|
||||||
|
match self {
|
||||||
|
PlayerSel::Mpv => crate::interactive::Player::Mpv,
|
||||||
|
PlayerSel::Vlc => crate::interactive::Player::Vlc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-screen state: the config form fields plus, once started, the running
|
||||||
|
/// child and the latest values parsed from its event stream.
|
||||||
|
struct HostState {
|
||||||
|
// form
|
||||||
|
quality: QualitySel,
|
||||||
|
max_viewers: u32, // 0 = let the host auto-size from the bandwidth preflight
|
||||||
|
no_hwencode: bool,
|
||||||
|
window: bool,
|
||||||
|
// running session + accumulated live state
|
||||||
|
proc: Option<ChildProc>,
|
||||||
|
ticket: Option<String>,
|
||||||
|
info: Option<HostInfo>,
|
||||||
|
active: u32,
|
||||||
|
max: u32,
|
||||||
|
capturing: bool,
|
||||||
|
last_refusal: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HostState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
quality: QualitySel::default(),
|
||||||
|
max_viewers: 0,
|
||||||
|
no_hwencode: false,
|
||||||
|
window: false,
|
||||||
|
proc: None,
|
||||||
|
ticket: None,
|
||||||
|
info: None,
|
||||||
|
active: 0,
|
||||||
|
max: 0,
|
||||||
|
capturing: false,
|
||||||
|
last_refusal: None,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host config summary echoed back by the child's `host_info` event.
|
||||||
|
struct HostInfo {
|
||||||
|
display: String,
|
||||||
|
capture: String,
|
||||||
|
quality: String,
|
||||||
|
dimensions: String,
|
||||||
|
hw_encode: bool,
|
||||||
|
cap_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Viewer-screen state.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ViewerState {
|
||||||
|
ticket_input: String,
|
||||||
|
player: PlayerSel,
|
||||||
|
proc: Option<ChildProc>,
|
||||||
|
url: Option<String>,
|
||||||
|
launched: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct PixelPassApp {
|
struct PixelPassApp {
|
||||||
screen: Screen,
|
screen: Screen,
|
||||||
|
host: HostState,
|
||||||
|
viewer: ViewerState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for PixelPassApp {
|
impl eframe::App for PixelPassApp {
|
||||||
// eframe 0.34 hands us the central-panel `ui` directly (it wraps this in a
|
// eframe 0.34 hands us the central-panel `ui` directly.
|
||||||
// CentralPanel for us).
|
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
// Drain any pending child events before drawing this frame.
|
||||||
|
self.pump_host_events();
|
||||||
|
self.pump_viewer_events();
|
||||||
|
|
||||||
match self.screen {
|
match self.screen {
|
||||||
Screen::Menu => self.menu(ui),
|
Screen::Menu => self.menu(ui),
|
||||||
Screen::Host => self.host(ui),
|
Screen::Host => self.host(ui),
|
||||||
@@ -64,7 +191,7 @@ impl PixelPassApp {
|
|||||||
ui.label("P2P screen sharing");
|
ui.label("P2P screen sharing");
|
||||||
ui.add_space(32.0);
|
ui.add_space(32.0);
|
||||||
if ui
|
if ui
|
||||||
.add_sized([240.0, 40.0], egui::Button::new("Host — share my screen"))
|
.add_sized([260.0, 40.0], egui::Button::new("Host — share my screen"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
self.screen = Screen::Host;
|
self.screen = Screen::Host;
|
||||||
@@ -72,7 +199,7 @@ impl PixelPassApp {
|
|||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
if ui
|
if ui
|
||||||
.add_sized(
|
.add_sized(
|
||||||
[240.0, 40.0],
|
[260.0, 40.0],
|
||||||
egui::Button::new("View — watch someone's screen"),
|
egui::Button::new("View — watch someone's screen"),
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -82,25 +209,390 @@ impl PixelPassApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Host screen ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn host(&mut self, ui: &mut egui::Ui) {
|
fn host(&mut self, ui: &mut egui::Ui) {
|
||||||
self.back_bar(ui, "Host");
|
let running = self.host.proc.is_some();
|
||||||
ui.separator();
|
|
||||||
ui.label("Host controls land in the next step.");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn viewer(&mut self, ui: &mut egui::Ui) {
|
|
||||||
self.back_bar(ui, "View");
|
|
||||||
ui.separator();
|
|
||||||
ui.label("Viewer controls land in a later step.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Title row with a back-to-menu button, shared by the host/viewer screens.
|
|
||||||
fn back_bar(&mut self, ui: &mut egui::Ui, title: &str) {
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
// Leaving the host screen stops the session (Drop on the child).
|
||||||
if ui.button("← Menu").clicked() {
|
if ui.button("← Menu").clicked() {
|
||||||
|
self.stop_host();
|
||||||
self.screen = Screen::Menu;
|
self.screen = Screen::Menu;
|
||||||
}
|
}
|
||||||
ui.heading(title);
|
ui.heading("Host");
|
||||||
});
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if running {
|
||||||
|
self.host_running(ui);
|
||||||
|
} else {
|
||||||
|
self.host_form(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_form(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if let Some(err) = &self.host.error {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_RED, err);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::Grid::new("host_form")
|
||||||
|
.num_columns(2)
|
||||||
|
.spacing([12.0, 10.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label("Quality");
|
||||||
|
egui::ComboBox::from_id_salt("quality")
|
||||||
|
.selected_text(self.host.quality.label())
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for q in QualitySel::ALL {
|
||||||
|
ui.selectable_value(&mut self.host.quality, q, q.label());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("Max viewers");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(egui::DragValue::new(&mut self.host.max_viewers).range(0..=16));
|
||||||
|
if self.host.max_viewers == 0 {
|
||||||
|
ui.label("(auto from upload speed)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("Options");
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.checkbox(&mut self.host.window, "Share a single window");
|
||||||
|
ui.checkbox(
|
||||||
|
&mut self.host.no_hwencode,
|
||||||
|
"Software encoding (no GPU / VAAPI)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
if ui
|
||||||
|
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.start_host(ui.ctx().clone());
|
||||||
|
}
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"On Wayland a \"Share Screen?\" dialog appears when the first \
|
||||||
|
viewer connects.",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_running(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if self.host.capturing {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
|
||||||
|
} else if self.host.ticket.is_some() {
|
||||||
|
ui.colored_label(egui::Color32::YELLOW, "● Waiting for viewers…");
|
||||||
|
} else {
|
||||||
|
ui.label("Starting…");
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
|
||||||
|
|
||||||
|
if let Some(info) = &self.host.info {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("{} · {}", info.display, info.capture))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"{} · {} · {} · cap {}",
|
||||||
|
info.quality,
|
||||||
|
info.dimensions,
|
||||||
|
if info.hw_encode {
|
||||||
|
"HW encode"
|
||||||
|
} else {
|
||||||
|
"software encode"
|
||||||
|
},
|
||||||
|
info.cap_source
|
||||||
|
))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
if let Some(ticket) = self.host.ticket.clone() {
|
||||||
|
ui.label("Share this code with your viewer(s):");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||||
|
ui.add(egui::Label::new(egui::RichText::new(&ticket).monospace().small()).wrap());
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
if ui.button("📋 Copy code").clicked() {
|
||||||
|
self.copy_to_clipboard(&ticket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reason) = &self.host.last_refusal {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.colored_label(
|
||||||
|
egui::Color32::from_rgb(220, 160, 60),
|
||||||
|
format!("⚠ {reason}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
if ui
|
||||||
|
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.stop_host();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_host(&mut self, ctx: egui::Context) {
|
||||||
|
self.host.error = None;
|
||||||
|
self.host.last_refusal = None;
|
||||||
|
self.host.ticket = None;
|
||||||
|
self.host.info = None;
|
||||||
|
self.host.active = 0;
|
||||||
|
self.host.max = 0;
|
||||||
|
self.host.capturing = false;
|
||||||
|
|
||||||
|
let mut args = vec![
|
||||||
|
"--host".to_string(),
|
||||||
|
"--output".to_string(),
|
||||||
|
"json".to_string(),
|
||||||
|
"--quality".to_string(),
|
||||||
|
self.host.quality.as_arg().to_string(),
|
||||||
|
];
|
||||||
|
if self.host.max_viewers > 0 {
|
||||||
|
args.push("--max-viewers".to_string());
|
||||||
|
args.push(self.host.max_viewers.to_string());
|
||||||
|
}
|
||||||
|
if self.host.no_hwencode {
|
||||||
|
args.push("--no-hwencode".to_string());
|
||||||
|
}
|
||||||
|
if self.host.window {
|
||||||
|
args.push("--window".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
match ChildProc::spawn(&args, ctx) {
|
||||||
|
Ok(p) => self.host.proc = Some(p),
|
||||||
|
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_host(&mut self) {
|
||||||
|
// Dropping the ChildProc SIGINTs the child and reaps it.
|
||||||
|
self.host.proc = None;
|
||||||
|
self.host.capturing = false;
|
||||||
|
self.host.ticket = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain the host child's event channel into state, and detect an
|
||||||
|
/// unexpected child exit (e.g. a failed dependency check) so it surfaces
|
||||||
|
/// in the form instead of leaving a dead "running" view.
|
||||||
|
fn pump_host_events(&mut self) {
|
||||||
|
let events: Vec<ChildEvent> = match &self.host.proc {
|
||||||
|
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
for ev in events {
|
||||||
|
self.apply_host_event(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(p) = &mut self.host.proc
|
||||||
|
&& !p.is_alive()
|
||||||
|
{
|
||||||
|
if self.host.ticket.is_none() {
|
||||||
|
let tail = p.stderr_tail();
|
||||||
|
self.host.error = Some(if tail.trim().is_empty() {
|
||||||
|
"Host exited before it could start.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Host exited before it could start:\n{tail}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.host.proc = None;
|
||||||
|
self.host.capturing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_host_event(&mut self, ev: ChildEvent) {
|
||||||
|
match ev {
|
||||||
|
ChildEvent::Ticket { value } => self.host.ticket = Some(value),
|
||||||
|
ChildEvent::HostInfo {
|
||||||
|
display_server,
|
||||||
|
capture,
|
||||||
|
quality,
|
||||||
|
dimensions,
|
||||||
|
hw_encode,
|
||||||
|
max_viewers,
|
||||||
|
max_viewers_source,
|
||||||
|
} => {
|
||||||
|
self.host.max = max_viewers;
|
||||||
|
self.host.info = Some(HostInfo {
|
||||||
|
display: display_server,
|
||||||
|
capture,
|
||||||
|
quality,
|
||||||
|
dimensions,
|
||||||
|
hw_encode,
|
||||||
|
cap_source: max_viewers_source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ChildEvent::ViewerCount { active, max } => {
|
||||||
|
self.host.active = active;
|
||||||
|
self.host.max = max;
|
||||||
|
}
|
||||||
|
ChildEvent::Capture { state } => {
|
||||||
|
self.host.capturing = matches!(state, child::CaptureState::Started);
|
||||||
|
}
|
||||||
|
ChildEvent::ViewerRefused { reason } => self.host.last_refusal = Some(reason),
|
||||||
|
ChildEvent::Connected { .. } => {} // viewer-side; not used on the host screen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_to_clipboard(&mut self, text: &str) {
|
||||||
|
if let Err(e) = arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text.to_owned())) {
|
||||||
|
self.host.error = Some(format!("Clipboard copy failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Viewer screen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn viewer(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let running = self.viewer.proc.is_some();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("← Menu").clicked() {
|
||||||
|
self.stop_viewer();
|
||||||
|
self.screen = Screen::Menu;
|
||||||
|
}
|
||||||
|
ui.heading("View");
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if running {
|
||||||
|
self.viewer_running(ui);
|
||||||
|
} else {
|
||||||
|
self.viewer_form(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewer_form(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if let Some(err) = &self.viewer.error {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_RED, err);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.label("Paste the share code you received:");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.viewer.ticket_input)
|
||||||
|
.desired_width(f32::INFINITY)
|
||||||
|
.hint_text("endpoint…"),
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Player");
|
||||||
|
egui::ComboBox::from_id_salt("player")
|
||||||
|
.selected_text(match self.viewer.player {
|
||||||
|
PlayerSel::Mpv => "mpv",
|
||||||
|
PlayerSel::Vlc => "VLC",
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut self.viewer.player, PlayerSel::Mpv, "mpv");
|
||||||
|
ui.selectable_value(&mut self.viewer.player, PlayerSel::Vlc, "VLC");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
let can_connect = !self.viewer.ticket_input.trim().is_empty();
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
can_connect,
|
||||||
|
egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.start_viewer(ui.ctx().clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewer_running(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if self.viewer.launched {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
|
||||||
|
ui.label("Player launched. Close it or disconnect to stop.");
|
||||||
|
} else if self.viewer.url.is_some() {
|
||||||
|
ui.label("Connected — launching player…");
|
||||||
|
} else {
|
||||||
|
ui.colored_label(egui::Color32::YELLOW, "● Connecting…");
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
if ui
|
||||||
|
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.stop_viewer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_viewer(&mut self, ctx: egui::Context) {
|
||||||
|
self.viewer.error = None;
|
||||||
|
self.viewer.url = None;
|
||||||
|
self.viewer.launched = false;
|
||||||
|
|
||||||
|
let ticket = self.viewer.ticket_input.trim().to_string();
|
||||||
|
let args = vec![ticket, "--output".to_string(), "json".to_string()];
|
||||||
|
match ChildProc::spawn(&args, ctx) {
|
||||||
|
Ok(p) => self.viewer.proc = Some(p),
|
||||||
|
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_viewer(&mut self) {
|
||||||
|
self.viewer.proc = None;
|
||||||
|
self.viewer.url = None;
|
||||||
|
self.viewer.launched = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump_viewer_events(&mut self) {
|
||||||
|
let events: Vec<ChildEvent> = match &self.viewer.proc {
|
||||||
|
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
for ev in events {
|
||||||
|
if let ChildEvent::Connected { url } = ev {
|
||||||
|
self.viewer.url = Some(url.clone());
|
||||||
|
if !self.viewer.launched {
|
||||||
|
match self.viewer.player.to_player().spawn(&url) {
|
||||||
|
Ok(()) => self.viewer.launched = true,
|
||||||
|
Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(p) = &mut self.viewer.proc
|
||||||
|
&& !p.is_alive()
|
||||||
|
{
|
||||||
|
// Bridge ended (player closed) or the connection failed.
|
||||||
|
if self.viewer.url.is_none() {
|
||||||
|
let tail = p.stderr_tail();
|
||||||
|
self.viewer.error = Some(if tail.trim().is_empty() {
|
||||||
|
"Couldn't connect to the host (check the code).".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Connection ended:\n{tail}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.viewer.proc = None;
|
||||||
|
self.viewer.launched = false;
|
||||||
|
self.viewer.url = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user