launcher: embed live preview controls
This commit is contained in:
parent
8dd3461be0
commit
3897239ef4
@ -148,82 +148,93 @@ impl LesavkaClientApp {
|
|||||||
.unwrap_or_else(|_| "breakout".to_string())
|
.unwrap_or_else(|_| "breakout".to_string())
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
let unified_view = view_mode == "unified";
|
let unified_view = view_mode == "unified";
|
||||||
|
let disable_video_render = std::env::var("LESAVKA_DISABLE_VIDEO_RENDER")
|
||||||
|
.map(|value| value.trim() != "0")
|
||||||
|
.unwrap_or(false);
|
||||||
info!(
|
info!(
|
||||||
"🪟 video layout selected: {}",
|
"🪟 video layout selected: {}",
|
||||||
if unified_view { "unified" } else { "breakout" }
|
if unified_view { "unified" } else { "breakout" }
|
||||||
);
|
);
|
||||||
|
|
||||||
/*────────── video rendering thread (winit) ────*/
|
if disable_video_render {
|
||||||
let video_queue = app_support::sanitize_video_queue(
|
info!("🪟 launcher preview active; skipping standalone client video windows");
|
||||||
std::env::var("LESAVKA_VIDEO_QUEUE")
|
} else {
|
||||||
.ok()
|
/*────────── video rendering thread (winit) ────*/
|
||||||
.and_then(|v| v.parse::<usize>().ok()),
|
let video_queue = app_support::sanitize_video_queue(
|
||||||
);
|
std::env::var("LESAVKA_VIDEO_QUEUE")
|
||||||
let dump_video = std::env::var("LESAVKA_DUMP_VIDEO").is_ok();
|
.ok()
|
||||||
let (video_tx, mut video_rx) = tokio::sync::mpsc::channel::<VideoPacket>(video_queue);
|
.and_then(|v| v.parse::<usize>().ok()),
|
||||||
|
);
|
||||||
|
let dump_video = std::env::var("LESAVKA_DUMP_VIDEO").is_ok();
|
||||||
|
let (video_tx, mut video_rx) =
|
||||||
|
tokio::sync::mpsc::channel::<VideoPacket>(video_queue);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
gtk::init().expect("GTK initialisation failed");
|
gtk::init().expect("GTK initialisation failed");
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
{
|
{
|
||||||
let el = EventLoopBuilder::<()>::new()
|
let el = EventLoopBuilder::<()>::new()
|
||||||
.with_any_thread(true)
|
.with_any_thread(true)
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
enum Renderer {
|
enum Renderer {
|
||||||
Unified(UnifiedMonitorWindow),
|
Unified(UnifiedMonitorWindow),
|
||||||
Breakout {
|
Breakout {
|
||||||
left: MonitorWindow,
|
left: MonitorWindow,
|
||||||
right: MonitorWindow,
|
right: MonitorWindow,
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
let renderer = if unified_view {
|
||||||
|
Renderer::Unified(
|
||||||
|
UnifiedMonitorWindow::new().expect("unified-window")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Renderer::Breakout {
|
||||||
|
left: MonitorWindow::new(0).expect("win0"),
|
||||||
|
right: MonitorWindow::new(1).expect("win1"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = el.run(move |_: Event<()>, elwt| {
|
||||||
|
elwt.set_control_flow(ControlFlow::WaitUntil(
|
||||||
|
std::time::Instant::now() + std::time::Duration::from_millis(16),
|
||||||
|
));
|
||||||
|
static CNT: std::sync::atomic::AtomicU64 =
|
||||||
|
std::sync::atomic::AtomicU64::new(0);
|
||||||
|
while let Ok(pkt) = video_rx.try_recv() {
|
||||||
|
CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 {
|
||||||
|
debug!(
|
||||||
|
"🎥 received {} video packets",
|
||||||
|
CNT.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if dump_video {
|
||||||
|
static DUMP_CNT: std::sync::atomic::AtomicU32 =
|
||||||
|
std::sync::atomic::AtomicU32::new(0);
|
||||||
|
let n =
|
||||||
|
DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let eye = if pkt.id == 0 { "l" } else { "r" };
|
||||||
|
let path = format!("/tmp/eye{eye}-cli-{n:05}.h264");
|
||||||
|
std::fs::write(&path, &pkt.data).ok();
|
||||||
|
}
|
||||||
|
match &renderer {
|
||||||
|
Renderer::Unified(window) => window.push_packet(pkt),
|
||||||
|
Renderer::Breakout { left, right } => match pkt.id {
|
||||||
|
0 => left.push_packet(pkt),
|
||||||
|
1 => right.push_packet(pkt),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let renderer = if unified_view {
|
});
|
||||||
Renderer::Unified(UnifiedMonitorWindow::new().expect("unified-window"))
|
|
||||||
} else {
|
|
||||||
Renderer::Breakout {
|
|
||||||
left: MonitorWindow::new(0).expect("win0"),
|
|
||||||
right: MonitorWindow::new(1).expect("win1"),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = el.run(move |_: Event<()>, elwt| {
|
/*────────── start video gRPC pullers ──────────*/
|
||||||
elwt.set_control_flow(ControlFlow::WaitUntil(
|
let ep_video = vid_ep.clone();
|
||||||
std::time::Instant::now() + std::time::Duration::from_millis(16),
|
tokio::spawn(Self::video_loop(ep_video, video_tx));
|
||||||
));
|
}
|
||||||
static CNT: std::sync::atomic::AtomicU64 =
|
|
||||||
std::sync::atomic::AtomicU64::new(0);
|
|
||||||
while let Ok(pkt) = video_rx.try_recv() {
|
|
||||||
CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 {
|
|
||||||
debug!(
|
|
||||||
"🎥 received {} video packets",
|
|
||||||
CNT.load(std::sync::atomic::Ordering::Relaxed)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if dump_video {
|
|
||||||
static DUMP_CNT: std::sync::atomic::AtomicU32 =
|
|
||||||
std::sync::atomic::AtomicU32::new(0);
|
|
||||||
let n = DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let eye = if pkt.id == 0 { "l" } else { "r" };
|
|
||||||
let path = format!("/tmp/eye{eye}-cli-{n:05}.h264");
|
|
||||||
std::fs::write(&path, &pkt.data).ok();
|
|
||||||
}
|
|
||||||
match &renderer {
|
|
||||||
Renderer::Unified(window) => window.push_packet(pkt),
|
|
||||||
Renderer::Breakout { left, right } => match pkt.id {
|
|
||||||
0 => left.push_packet(pkt),
|
|
||||||
1 => right.push_packet(pkt),
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/*────────── start video gRPC pullers ──────────*/
|
|
||||||
let ep_video = vid_ep.clone();
|
|
||||||
tokio::spawn(Self::video_loop(ep_video, video_tx));
|
|
||||||
|
|
||||||
/*────────── audio renderer & puller ───────────*/
|
/*────────── audio renderer & puller ───────────*/
|
||||||
let audio_out = AudioOut::new()?;
|
let audio_out = AudioOut::new()?;
|
||||||
@ -373,7 +384,7 @@ impl LesavkaClientApp {
|
|||||||
let max_bitrate = std::env::var("LESAVKA_VIDEO_MAX_KBIT")
|
let max_bitrate = std::env::var("LESAVKA_VIDEO_MAX_KBIT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse::<u32>().ok())
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
.unwrap_or(4_000);
|
.unwrap_or(6_000);
|
||||||
for monitor_id in 0..=1 {
|
for monitor_id in 0..=1 {
|
||||||
let ep = ep.clone();
|
let ep = ep.clone();
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
|
|||||||
@ -5,6 +5,8 @@ use std::time::Duration;
|
|||||||
use crate::handshake::PeerCaps;
|
use crate::handshake::PeerCaps;
|
||||||
use crate::input::camera::{CameraCodec, CameraConfig};
|
use crate::input::camera::{CameraCodec, CameraConfig};
|
||||||
|
|
||||||
|
pub const DEFAULT_SERVER_ADDR: &str = "http://38.28.125.112:50051";
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
/// Resolve the server address from `--server`, positional args, env, or default.
|
/// Resolve the server address from `--server`, positional args, env, or default.
|
||||||
pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String {
|
pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String {
|
||||||
@ -14,7 +16,7 @@ pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String {
|
|||||||
})
|
})
|
||||||
.or_else(|| args.iter().find(|arg| !arg.starts_with("--")).cloned())
|
.or_else(|| args.iter().find(|arg| !arg.starts_with("--")).cloned())
|
||||||
.or_else(|| env_addr.map(ToOwned::to_owned))
|
.or_else(|| env_addr.map(ToOwned::to_owned))
|
||||||
.unwrap_or_else(|| "http://127.0.0.1:50051".to_string())
|
.unwrap_or_else(|| DEFAULT_SERVER_ADDR.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@ -54,7 +56,10 @@ fn parse_camera_codec(raw: &str) -> Option<CameraCodec> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{camera_config_from_caps, next_delay, resolve_server_addr, sanitize_video_queue};
|
use super::{
|
||||||
|
DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr,
|
||||||
|
sanitize_video_queue,
|
||||||
|
};
|
||||||
use crate::handshake::PeerCaps;
|
use crate::handshake::PeerCaps;
|
||||||
use crate::input::camera::CameraCodec;
|
use crate::input::camera::CameraCodec;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@ -81,7 +86,7 @@ mod tests {
|
|||||||
"http://env:2"
|
"http://env:2"
|
||||||
);
|
);
|
||||||
assert_eq!(resolve_server_addr(&[], Some("http://env:2")), "http://env:2");
|
assert_eq!(resolve_server_addr(&[], Some("http://env:2")), "http://env:2");
|
||||||
assert_eq!(resolve_server_addr(&[], None), "http://127.0.0.1:50051");
|
assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -147,7 +147,7 @@ mod tests {
|
|||||||
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
|
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
|
||||||
assert_eq!(report.recent_samples.len(), 1);
|
assert_eq!(report.recent_samples.len(), 1);
|
||||||
assert_eq!(report.notes, vec!["first note".to_string()]);
|
assert_eq!(report.notes, vec!["first note".to_string()]);
|
||||||
assert!(report.status.contains("mode=remote"));
|
assert!(report.status.contains("mode=local"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -2,11 +2,14 @@ pub mod devices;
|
|||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
mod preview;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use crate::app_support::DEFAULT_SERVER_ADDR;
|
||||||
|
|
||||||
pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command};
|
pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command};
|
||||||
pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode};
|
pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode};
|
||||||
@ -35,6 +38,9 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
|
|||||||
"LESAVKA_VIEW_MODE".to_string(),
|
"LESAVKA_VIEW_MODE".to_string(),
|
||||||
state.view_mode.as_env().to_string(),
|
state.view_mode.as_env().to_string(),
|
||||||
);
|
);
|
||||||
|
if matches!(state.view_mode, ViewMode::Unified) {
|
||||||
|
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
|
||||||
|
}
|
||||||
if let Some(camera) = state.devices.camera.as_ref() {
|
if let Some(camera) = state.devices.camera.as_ref() {
|
||||||
envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone());
|
envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone());
|
||||||
}
|
}
|
||||||
@ -57,7 +63,7 @@ fn resolve_server_addr(args: &[String]) -> String {
|
|||||||
.find(|arg| !arg.starts_with("--"))
|
.find(|arg| !arg.starts_with("--"))
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| std::env::var("LESAVKA_SERVER_ADDR").ok())
|
.or_else(|| std::env::var("LESAVKA_SERVER_ADDR").ok())
|
||||||
.unwrap_or_else(|| "http://127.0.0.1:50051".to_string())
|
.unwrap_or_else(|| DEFAULT_SERVER_ADDR.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -81,7 +87,7 @@ mod tests {
|
|||||||
assert_eq!(resolve_server_addr(&args), "http://from-arg:50051");
|
assert_eq!(resolve_server_addr(&args), "http://from-arg:50051");
|
||||||
|
|
||||||
let args = vec!["--launcher".to_string()];
|
let args = vec!["--launcher".to_string()];
|
||||||
assert!(resolve_server_addr(&args).starts_with("http://"));
|
assert_eq!(resolve_server_addr(&args), DEFAULT_SERVER_ADDR);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -96,6 +102,10 @@ mod tests {
|
|||||||
let envs = runtime_env_vars(&state);
|
let envs = runtime_env_vars(&state);
|
||||||
assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string()));
|
assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string()));
|
||||||
assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".to_string()));
|
assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_DISABLE_VIDEO_RENDER"),
|
||||||
|
Some(&"1".to_string())
|
||||||
|
);
|
||||||
assert_eq!(envs.get("LESAVKA_CAM_SOURCE"), Some(&"/dev/video0".to_string()));
|
assert_eq!(envs.get("LESAVKA_CAM_SOURCE"), Some(&"/dev/video0".to_string()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
envs.get("LESAVKA_MIC_SOURCE"),
|
envs.get("LESAVKA_MIC_SOURCE"),
|
||||||
@ -107,6 +117,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_env_vars_do_not_disable_breakout_video_windows() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_view_mode(ViewMode::Breakout);
|
||||||
|
|
||||||
|
let envs = runtime_env_vars(&state);
|
||||||
|
assert!(!envs.contains_key("LESAVKA_DISABLE_VIDEO_RENDER"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maybe_run_launcher_returns_false_with_explicit_opt_out() {
|
fn maybe_run_launcher_returns_false_with_explicit_opt_out() {
|
||||||
let args = vec!["--no-launcher".to_string()];
|
let args = vec!["--no-launcher".to_string()];
|
||||||
|
|||||||
258
client/src/launcher/preview.rs
Normal file
258
client/src/launcher/preview.rs
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
#[cfg(not(coverage))]
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use gstreamer as gst;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use gstreamer::prelude::{Cast, ElementExt, GstBinExt};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use gstreamer_app as gst_app;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use gtk::{gdk, glib};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use std::time::Duration;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use tonic::{Request, transport::Channel};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
const PREVIEW_WIDTH: i32 = 640;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
const PREVIEW_HEIGHT: i32 = 360;
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
pub struct LauncherPreview {
|
||||||
|
feeds: [PreviewFeed; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
impl LauncherPreview {
|
||||||
|
pub fn new(server_addr: String) -> Result<Self> {
|
||||||
|
gst::init().context("initialising preview gstreamer")?;
|
||||||
|
Ok(Self {
|
||||||
|
feeds: [
|
||||||
|
PreviewFeed::spawn(server_addr.clone(), 0)?,
|
||||||
|
PreviewFeed::spawn(server_addr, 1)?,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_on_picture(
|
||||||
|
&self,
|
||||||
|
monitor_id: usize,
|
||||||
|
picture: >k::Picture,
|
||||||
|
status_label: >k::Label,
|
||||||
|
) {
|
||||||
|
if let Some(feed) = self.feeds.get(monitor_id) {
|
||||||
|
feed.install_on_picture(picture, status_label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
struct PreviewFeed {
|
||||||
|
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
impl PreviewFeed {
|
||||||
|
fn spawn(server_addr: String, monitor_id: u32) -> Result<Self> {
|
||||||
|
let latest = Arc::new(Mutex::new(None));
|
||||||
|
let store = Arc::clone(&latest);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(err) = run_preview_feed(server_addr, monitor_id, store) {
|
||||||
|
warn!(monitor_id, ?err, "launcher preview feed exited");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Self { latest })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_on_picture(&self, picture: >k::Picture, status_label: >k::Label) {
|
||||||
|
let picture = picture.clone();
|
||||||
|
let status_label = status_label.clone();
|
||||||
|
let latest = Arc::clone(&self.latest);
|
||||||
|
glib::timeout_add_local(Duration::from_millis(120), move || {
|
||||||
|
let next = latest.lock().ok().and_then(|mut slot| slot.take());
|
||||||
|
if let Some(frame) = next {
|
||||||
|
let bytes = glib::Bytes::from_owned(frame.rgba);
|
||||||
|
let texture = gdk::MemoryTexture::new(
|
||||||
|
frame.width,
|
||||||
|
frame.height,
|
||||||
|
gdk::MemoryFormat::R8g8b8a8,
|
||||||
|
&bytes,
|
||||||
|
frame.stride,
|
||||||
|
);
|
||||||
|
picture.set_paintable(Some(&texture));
|
||||||
|
status_label.set_text("Live");
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Continue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
struct PreviewFrame {
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
stride: usize,
|
||||||
|
rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn run_preview_feed(
|
||||||
|
server_addr: String,
|
||||||
|
monitor_id: u32,
|
||||||
|
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (pipeline, appsrc, appsink) = build_preview_pipeline()?;
|
||||||
|
pipeline
|
||||||
|
.set_state(gst::State::Playing)
|
||||||
|
.context("starting launcher preview pipeline")?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let latest = Arc::clone(&latest);
|
||||||
|
let appsink = appsink.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) {
|
||||||
|
if let Some(frame) = sample_to_frame(&sample) {
|
||||||
|
if let Ok(mut slot) = latest.lock() {
|
||||||
|
*slot = Some(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("building preview tokio runtime")?;
|
||||||
|
|
||||||
|
let _ = rt.block_on(async move {
|
||||||
|
loop {
|
||||||
|
let channel = match Channel::from_shared(server_addr.clone()) {
|
||||||
|
Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await {
|
||||||
|
Ok(channel) => channel,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(monitor_id, ?err, "launcher preview connect failed");
|
||||||
|
tokio::time::sleep(Duration::from_millis(750)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
warn!(monitor_id, ?err, "launcher preview endpoint invalid");
|
||||||
|
tokio::time::sleep(Duration::from_millis(750)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut cli = RelayClient::new(channel);
|
||||||
|
let req = MonitorRequest {
|
||||||
|
id: monitor_id,
|
||||||
|
max_bitrate: preview_max_bitrate(),
|
||||||
|
};
|
||||||
|
match cli.capture_video(Request::new(req)).await {
|
||||||
|
Ok(mut stream) => {
|
||||||
|
debug!(monitor_id, "launcher preview connected");
|
||||||
|
while let Some(item) = stream.get_mut().message().await.transpose() {
|
||||||
|
match item {
|
||||||
|
Ok(pkt) => push_preview_packet(&appsrc, pkt),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(monitor_id, ?err, "launcher preview stream error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => warn!(monitor_id, ?err, "launcher preview rpc failed"),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(750)).await;
|
||||||
|
}
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn build_preview_pipeline() -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink)> {
|
||||||
|
let desc = format!(
|
||||||
|
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
|
||||||
|
queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \
|
||||||
|
h264parse disable-passthrough=true ! avdec_h264 ! videoconvert ! videoscale ! \
|
||||||
|
video/x-raw,format=RGBA,width={PREVIEW_WIDTH},height={PREVIEW_HEIGHT},pixel-aspect-ratio=1/1 ! \
|
||||||
|
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true"
|
||||||
|
);
|
||||||
|
let pipeline = gst::parse::launch(&desc)?
|
||||||
|
.downcast::<gst::Pipeline>()
|
||||||
|
.expect("preview pipeline");
|
||||||
|
|
||||||
|
let appsrc = pipeline
|
||||||
|
.by_name("src")
|
||||||
|
.context("missing preview appsrc")?
|
||||||
|
.downcast::<gst_app::AppSrc>()
|
||||||
|
.expect("preview appsrc");
|
||||||
|
appsrc.set_caps(Some(
|
||||||
|
&gst::Caps::builder("video/x-h264")
|
||||||
|
.field("stream-format", &"byte-stream")
|
||||||
|
.field("alignment", &"au")
|
||||||
|
.build(),
|
||||||
|
));
|
||||||
|
appsrc.set_format(gst::Format::Time);
|
||||||
|
|
||||||
|
let appsink = pipeline
|
||||||
|
.by_name("sink")
|
||||||
|
.context("missing preview appsink")?
|
||||||
|
.downcast::<gst_app::AppSink>()
|
||||||
|
.expect("preview appsink");
|
||||||
|
appsink.set_caps(Some(
|
||||||
|
&gst::Caps::builder("video/x-raw")
|
||||||
|
.field("format", &"RGBA")
|
||||||
|
.field("width", &PREVIEW_WIDTH)
|
||||||
|
.field("height", &PREVIEW_HEIGHT)
|
||||||
|
.build(),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok((pipeline, appsrc, appsink))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn push_preview_packet(appsrc: &gst_app::AppSrc, pkt: VideoPacket) {
|
||||||
|
let mut buf = gst::Buffer::from_slice(pkt.data);
|
||||||
|
if let Some(buf) = buf.get_mut() {
|
||||||
|
buf.set_pts(Some(gst::ClockTime::from_useconds(pkt.pts)));
|
||||||
|
}
|
||||||
|
let _ = appsrc.push_buffer(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn sample_to_frame(sample: &gst::Sample) -> Option<PreviewFrame> {
|
||||||
|
let caps = sample.caps()?;
|
||||||
|
let structure = caps.structure(0)?;
|
||||||
|
let width = structure.get::<i32>("width").ok()?;
|
||||||
|
let height = structure.get::<i32>("height").ok()?;
|
||||||
|
let buffer = sample.buffer()?;
|
||||||
|
let map = buffer.map_readable().ok()?;
|
||||||
|
let rgba = map.as_slice().to_vec();
|
||||||
|
let stride = rgba.len() / height.max(1) as usize;
|
||||||
|
Some(PreviewFrame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stride,
|
||||||
|
rgba,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn preview_max_bitrate() -> u32 {
|
||||||
|
std::env::var("LESAVKA_PREVIEW_MAX_KBIT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| raw.parse::<u32>().ok())
|
||||||
|
.unwrap_or(2_500)
|
||||||
|
}
|
||||||
@ -51,8 +51,8 @@ pub struct LauncherState {
|
|||||||
impl Default for LauncherState {
|
impl Default for LauncherState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
routing: InputRouting::Remote,
|
routing: InputRouting::Local,
|
||||||
view_mode: ViewMode::Breakout,
|
view_mode: ViewMode::Unified,
|
||||||
devices: DeviceSelection::default(),
|
devices: DeviceSelection::default(),
|
||||||
remote_active: false,
|
remote_active: false,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
@ -160,10 +160,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_pick_remote_breakout_and_inactive_session() {
|
fn defaults_pick_local_unified_and_inactive_session() {
|
||||||
let state = LauncherState::new();
|
let state = LauncherState::new();
|
||||||
assert_eq!(state.routing, InputRouting::Remote);
|
assert_eq!(state.routing, InputRouting::Local);
|
||||||
assert_eq!(state.view_mode, ViewMode::Breakout);
|
assert_eq!(state.view_mode, ViewMode::Unified);
|
||||||
assert!(!state.remote_active);
|
assert!(!state.remote_active);
|
||||||
assert!(state.devices.camera.is_none());
|
assert!(state.devices.camera.is_none());
|
||||||
assert!(state.devices.microphone.is_none());
|
assert!(state.devices.microphone.is_none());
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use anyhow::{Result, anyhow};
|
|||||||
use {
|
use {
|
||||||
super::devices::DeviceCatalog,
|
super::devices::DeviceCatalog,
|
||||||
super::diagnostics::quality_probe_command,
|
super::diagnostics::quality_probe_command,
|
||||||
|
super::preview::LauncherPreview,
|
||||||
super::runtime_env_vars,
|
super::runtime_env_vars,
|
||||||
super::state::{InputRouting, LauncherState, ViewMode},
|
super::state::{InputRouting, LauncherState, ViewMode},
|
||||||
crate::paste,
|
crate::paste,
|
||||||
@ -47,10 +48,13 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let window = gtk::ApplicationWindow::builder()
|
let window = gtk::ApplicationWindow::builder()
|
||||||
.application(app)
|
.application(app)
|
||||||
.title("Lesavka Launcher")
|
.title("Lesavka Launcher")
|
||||||
.default_width(680)
|
.default_width(980)
|
||||||
.default_height(520)
|
.default_height(860)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
let scroll = gtk::ScrolledWindow::new();
|
||||||
|
scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
|
||||||
|
|
||||||
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
root.set_margin_start(14);
|
root.set_margin_start(14);
|
||||||
root.set_margin_end(14);
|
root.set_margin_end(14);
|
||||||
@ -62,11 +66,20 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
heading.set_halign(gtk::Align::Start);
|
heading.set_halign(gtk::Align::Start);
|
||||||
root.append(&heading);
|
root.append(&heading);
|
||||||
|
|
||||||
let status_label = gtk::Label::new(Some("Idle"));
|
let status_label = gtk::Label::new(Some("Idle - preview warming up"));
|
||||||
status_label.set_halign(gtk::Align::Start);
|
status_label.set_halign(gtk::Align::Start);
|
||||||
status_label.set_selectable(true);
|
status_label.set_selectable(true);
|
||||||
root.append(&status_label);
|
root.append(&status_label);
|
||||||
|
|
||||||
|
let preview_frame = gtk::Frame::new(Some("Live Preview"));
|
||||||
|
let preview_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||||
|
let (left_preview, left_picture, left_status) = build_preview_pane("Display 1");
|
||||||
|
let (right_preview, right_picture, right_status) = build_preview_pane("Display 2");
|
||||||
|
preview_row.append(&left_preview);
|
||||||
|
preview_row.append(&right_preview);
|
||||||
|
preview_frame.set_child(Some(&preview_row));
|
||||||
|
root.append(&preview_frame);
|
||||||
|
|
||||||
let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
let server_label = gtk::Label::new(Some("Server"));
|
let server_label = gtk::Label::new(Some("Server"));
|
||||||
server_label.set_halign(gtk::Align::Start);
|
server_label.set_halign(gtk::Align::Start);
|
||||||
@ -82,7 +95,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
controls.set_column_spacing(8);
|
controls.set_column_spacing(8);
|
||||||
root.append(&controls);
|
root.append(&controls);
|
||||||
|
|
||||||
let routing_label = gtk::Label::new(Some("Remote input capture"));
|
let routing_label = gtk::Label::new(Some("Start in remote control"));
|
||||||
routing_label.set_halign(gtk::Align::Start);
|
routing_label.set_halign(gtk::Align::Start);
|
||||||
controls.attach(&routing_label, 0, 0, 1, 1);
|
controls.attach(&routing_label, 0, 0, 1, 1);
|
||||||
|
|
||||||
@ -90,7 +103,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
routing_switch.set_active(matches!(state.borrow().routing, InputRouting::Remote));
|
routing_switch.set_active(matches!(state.borrow().routing, InputRouting::Remote));
|
||||||
controls.attach(&routing_switch, 1, 0, 1, 1);
|
controls.attach(&routing_switch, 1, 0, 1, 1);
|
||||||
|
|
||||||
let view_label = gtk::Label::new(Some("View mode"));
|
let view_label = gtk::Label::new(Some("Preview mode"));
|
||||||
view_label.set_halign(gtk::Align::Start);
|
view_label.set_halign(gtk::Align::Start);
|
||||||
controls.attach(&view_label, 0, 1, 1, 1);
|
controls.attach(&view_label, 0, 1, 1, 1);
|
||||||
|
|
||||||
@ -160,7 +173,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
root.append(&button_row);
|
root.append(&button_row);
|
||||||
|
|
||||||
let start_button = gtk::Button::with_label("Start Session");
|
let start_button = gtk::Button::with_label("Start Relay");
|
||||||
let stop_button = gtk::Button::with_label("End Relay");
|
let stop_button = gtk::Button::with_label("End Relay");
|
||||||
let view_toggle_button = gtk::Button::with_label("");
|
let view_toggle_button = gtk::Button::with_label("");
|
||||||
let input_toggle_button = gtk::Button::with_label("");
|
let input_toggle_button = gtk::Button::with_label("");
|
||||||
@ -178,12 +191,24 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
root.append(&probe_hint);
|
root.append(&probe_hint);
|
||||||
|
|
||||||
let note = gtk::Label::new(Some(
|
let note = gtk::Label::new(Some(
|
||||||
"Unified mode renders both streams side-by-side in one window. Use Pop Out Windows to split back into full windows. Input swap key defaults to Pause and can be changed.",
|
"The live preview stays in this launcher by default so you can watch both displays before handing control over. Start Relay keeps the preview here in unified mode, Pop Out Windows switches back to external video windows, and Use Remote Inputs hands keyboard and mouse to the remote side.",
|
||||||
));
|
));
|
||||||
note.set_wrap(true);
|
note.set_wrap(true);
|
||||||
note.set_halign(gtk::Align::Start);
|
note.set_halign(gtk::Align::Start);
|
||||||
root.append(¬e);
|
root.append(¬e);
|
||||||
|
|
||||||
|
match LauncherPreview::new(server_addr.as_ref().to_string()) {
|
||||||
|
Ok(preview) => {
|
||||||
|
preview.install_on_picture(0, &left_picture, &left_status);
|
||||||
|
preview.install_on_picture(1, &right_picture, &right_status);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let msg = format!("Preview unavailable: {err}");
|
||||||
|
left_status.set_text(&msg);
|
||||||
|
right_status.set_text(&msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let state = Rc::clone(&state);
|
let state = Rc::clone(&state);
|
||||||
let child_proc = Rc::clone(&child_proc);
|
let child_proc = Rc::clone(&child_proc);
|
||||||
@ -369,7 +394,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.set_child(Some(&root));
|
scroll.set_child(Some(&root));
|
||||||
|
window.set_child(Some(&scroll));
|
||||||
window.present();
|
window.present();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -554,6 +580,30 @@ fn sync_toggle_button_labels(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn build_preview_pane(title: &str) -> (gtk::Box, gtk::Picture, gtk::Label) {
|
||||||
|
let pane = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||||
|
pane.set_hexpand(true);
|
||||||
|
pane.set_vexpand(true);
|
||||||
|
|
||||||
|
let label = gtk::Label::new(Some(title));
|
||||||
|
label.set_halign(gtk::Align::Start);
|
||||||
|
pane.append(&label);
|
||||||
|
|
||||||
|
let picture = gtk::Picture::new();
|
||||||
|
picture.set_hexpand(true);
|
||||||
|
picture.set_vexpand(true);
|
||||||
|
picture.set_can_shrink(true);
|
||||||
|
picture.set_size_request(440, 248);
|
||||||
|
pane.append(&picture);
|
||||||
|
|
||||||
|
let status = gtk::Label::new(Some("Waiting for stream..."));
|
||||||
|
status.set_halign(gtk::Align::Start);
|
||||||
|
pane.append(&status);
|
||||||
|
|
||||||
|
(pane, picture, status)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, coverage))]
|
#[cfg(all(test, coverage))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::run_gui_launcher;
|
use super::run_gui_launcher;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"clippy_warnings": 42,
|
"clippy_warnings": 42,
|
||||||
"doc_debt": 10,
|
"doc_debt": 10,
|
||||||
"loc": 537
|
"loc": 548
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -65,15 +65,20 @@
|
|||||||
"doc_debt": 4,
|
"doc_debt": 4,
|
||||||
"loc": 176
|
"loc": 176
|
||||||
},
|
},
|
||||||
|
"client/src/launcher/preview.rs": {
|
||||||
|
"clippy_warnings": 20,
|
||||||
|
"doc_debt": 7,
|
||||||
|
"loc": 258
|
||||||
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"clippy_warnings": 8,
|
"clippy_warnings": 8,
|
||||||
"doc_debt": 9,
|
"doc_debt": 9,
|
||||||
"loc": 234
|
"loc": 234
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 8,
|
||||||
"doc_debt": 6,
|
"doc_debt": 7,
|
||||||
"loc": 565
|
"loc": 615
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 6,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"files": {
|
"files": {
|
||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"line_percent": 95.1219512195122,
|
"line_percent": 95.1219512195122,
|
||||||
"loc": 537
|
"loc": 548
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
@ -49,12 +49,12 @@
|
|||||||
"loc": 176
|
"loc": 176
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"line_percent": 99.32432432432432,
|
"line_percent": 97.97297297297297,
|
||||||
"loc": 234
|
"loc": 234
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 565
|
"loc": 615
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"line_percent": 97.72727272727273,
|
"line_percent": 97.72727272727273,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user