b6240c17c5
The screen-share viewer ran mpv with --profile=low-latency (hwdec off by default) and then forced --hwdec=auto back on. On some drivers the HW H.264 decoder stalls mid-stream: a viewer receiving a software-x264 share froze on the first frame while audio kept playing (one MPEG-TS byte stream, so bytes were still flowing — the video decoder gave up, the audio decoder didn't). Screen-share H.264 at these bitrates decodes trivially in software, so leave hwdec at the low-latency default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
353 lines
12 KiB
Rust
353 lines
12 KiB
Rust
use anyhow::Result;
|
|
use dialoguer::{Input, Select, theme::ColorfulTheme};
|
|
use iroh_tickets::endpoint::EndpointTicket;
|
|
use std::str::FromStr;
|
|
|
|
use crate::cli::{Cli, Quality};
|
|
use crate::common::{bandwidth, config};
|
|
use crate::{host, viewer};
|
|
|
|
pub async fn run(cli: Cli) -> Result<()> {
|
|
print_welcome();
|
|
|
|
let theme = ColorfulTheme::default();
|
|
let choice = Select::with_theme(&theme)
|
|
.with_prompt("What do you want to do?")
|
|
.items([
|
|
"Host (share my screen)",
|
|
"View (watch someone else's screen)",
|
|
])
|
|
.default(0)
|
|
.interact()?;
|
|
|
|
match choice {
|
|
0 => {
|
|
preflight_if_needed(&theme).await;
|
|
let mut cli = cli;
|
|
if cli.app.is_none() {
|
|
cli.app = pick_app(&theme)?;
|
|
}
|
|
if cli.quality.is_none() {
|
|
cli.quality = Some(pick_quality(&theme)?);
|
|
}
|
|
host::run(cli.into_host_opts(true)).await
|
|
}
|
|
_ => {
|
|
let ticket = prompt_ticket(&theme)?;
|
|
viewer::run(ticket, cli.into_viewer_opts(true)).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Picker for the per-app audio capture choice. Lists apps currently
|
|
/// producing audio (deduped by `application.name`); user picks one or
|
|
/// the "all system audio" default. Bypassed when `--app NAME` was given
|
|
/// on the CLI.
|
|
fn pick_app(theme: &ColorfulTheme) -> Result<Option<String>> {
|
|
let apps = match host::audio::list_playing_apps() {
|
|
Ok(a) => a,
|
|
Err(e) => {
|
|
tracing::warn!("could not enumerate playing apps: {e:#}");
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
eprintln!();
|
|
eprintln!("Audio capture");
|
|
eprintln!("─────────────");
|
|
if apps.is_empty() {
|
|
eprintln!("No other apps are currently producing audio.");
|
|
eprintln!("Start your game / music / call first if you want to pick it specifically.");
|
|
}
|
|
eprintln!();
|
|
|
|
let mut items = vec!["Capture all system audio (default)".to_string()];
|
|
for app in &apps {
|
|
items.push(if app.stream_count == 1 {
|
|
app.name.clone()
|
|
} else {
|
|
format!("{} ({} streams)", app.name, app.stream_count)
|
|
});
|
|
}
|
|
|
|
let choice = Select::with_theme(theme)
|
|
.with_prompt("What audio should the viewer hear?")
|
|
.items(&items)
|
|
.default(0)
|
|
.interact()?;
|
|
|
|
if choice == 0 {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(apps[choice - 1].name.clone()))
|
|
}
|
|
}
|
|
|
|
/// Picker for the encode quality preset. Mirrors [`pick_app`]; bypassed when
|
|
/// `--quality` was given on the CLI. Quality is host-global (the same stream
|
|
/// fans out to every viewer), so this is the one choice that sets it for all.
|
|
fn pick_quality(theme: &ColorfulTheme) -> Result<Quality> {
|
|
eprintln!();
|
|
eprintln!("Quality");
|
|
eprintln!("───────");
|
|
eprintln!("Lower presets trade resolution + bitrate for less upload usage.");
|
|
eprintln!("The same quality is sent to every viewer.");
|
|
eprintln!();
|
|
|
|
// Order mirrors the labels below; index maps back to a Quality.
|
|
let choices = [
|
|
Quality::Auto,
|
|
Quality::Source,
|
|
Quality::High,
|
|
Quality::Medium,
|
|
Quality::Low,
|
|
];
|
|
let items = [
|
|
"Auto — pick from my measured upstream (recommended)",
|
|
"Source — native resolution, 6000 kbps",
|
|
"High — up to 1080p, 4000 kbps",
|
|
"Medium — up to 720p, 2500 kbps",
|
|
"Low — up to 480p, 1000 kbps",
|
|
];
|
|
|
|
let choice = Select::with_theme(theme)
|
|
.with_prompt("What quality should the viewer(s) get?")
|
|
.items(items)
|
|
.default(0)
|
|
.interact()?;
|
|
|
|
Ok(choices[choice])
|
|
}
|
|
|
|
/// `pixelpass --reconfigure` entry point: unconditionally re-run the
|
|
/// bandwidth pre-flight test, save the result, and return. Used to
|
|
/// refresh a stale measurement (e.g. user moved house, changed ISP).
|
|
pub async fn run_reconfigure() -> Result<()> {
|
|
eprintln!();
|
|
eprintln!("Re-running bandwidth pre-flight test…");
|
|
let mut cfg = config::load().unwrap_or_default();
|
|
run_bandwidth_test(&mut cfg).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// First-run pre-flight gate. Called once, when the user picks "Host" in
|
|
/// the interactive menu. Behavior by saved status:
|
|
/// - Unmeasured (first ever launch): explain + offer Run / Skip
|
|
/// - Failed (previous attempt errored): offer Retry / give-up-and-skip
|
|
/// - Measured or Skipped: silent — never re-prompts
|
|
async fn preflight_if_needed(theme: &ColorfulTheme) {
|
|
let mut cfg = config::load().unwrap_or_default();
|
|
match cfg.bandwidth.status {
|
|
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => (),
|
|
config::BandwidthStatus::Unmeasured => {
|
|
eprintln!();
|
|
eprintln!("First-time setup");
|
|
eprintln!("────────────────");
|
|
eprintln!("PixelPass can measure your upload speed to recommend a safe");
|
|
eprintln!("default for how many viewers your connection can handle.");
|
|
eprintln!("The test takes about 5 seconds and uploads ~5 MB to");
|
|
eprintln!("Cloudflare's open speed-test endpoint.");
|
|
eprintln!();
|
|
eprintln!("If you skip, a conservative default (2 viewers) is used.");
|
|
eprintln!("You can run the test later with `pixelpass --reconfigure`.");
|
|
eprintln!();
|
|
|
|
let Ok(choice) = Select::with_theme(theme)
|
|
.with_prompt("What would you like to do?")
|
|
.items([
|
|
"Run the bandwidth test (recommended)",
|
|
"Skip — use the conservative default",
|
|
])
|
|
.default(0)
|
|
.interact()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if choice == 1 {
|
|
cfg.bandwidth = config::BandwidthEntry {
|
|
status: config::BandwidthStatus::Skipped,
|
|
upstream_mbps: None,
|
|
measured_at: None,
|
|
};
|
|
let _ = config::save(&cfg);
|
|
eprintln!("Pre-flight skipped.");
|
|
return;
|
|
}
|
|
run_bandwidth_test(&mut cfg).await;
|
|
}
|
|
config::BandwidthStatus::Failed => {
|
|
eprintln!();
|
|
let Ok(choice) = Select::with_theme(theme)
|
|
.with_prompt("Last bandwidth test failed. Try again?")
|
|
.items(["Yes — retry now", "No — use the conservative default"])
|
|
.default(0)
|
|
.interact()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if choice == 1 {
|
|
cfg.bandwidth = config::BandwidthEntry {
|
|
status: config::BandwidthStatus::Skipped,
|
|
upstream_mbps: None,
|
|
measured_at: None,
|
|
};
|
|
let _ = config::save(&cfg);
|
|
eprintln!("OK — using the conservative default.");
|
|
return;
|
|
}
|
|
run_bandwidth_test(&mut cfg).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_bandwidth_test(cfg: &mut config::Config) {
|
|
eprintln!();
|
|
eprintln!("Measuring upstream…");
|
|
|
|
let result = tokio::task::spawn_blocking(bandwidth::measure_upstream_blocking).await;
|
|
let measurement = match result {
|
|
Ok(Ok(m)) => m,
|
|
Ok(Err(e)) => {
|
|
eprintln!("Test failed: {e:#}");
|
|
eprintln!("Marking as failed — you'll be asked again on next launch.");
|
|
cfg.bandwidth = config::BandwidthEntry {
|
|
status: config::BandwidthStatus::Failed,
|
|
upstream_mbps: None,
|
|
measured_at: None,
|
|
};
|
|
let _ = config::save(cfg);
|
|
return;
|
|
}
|
|
Err(join_err) => {
|
|
eprintln!("Test task panicked: {join_err}");
|
|
cfg.bandwidth = config::BandwidthEntry {
|
|
status: config::BandwidthStatus::Failed,
|
|
upstream_mbps: None,
|
|
measured_at: None,
|
|
};
|
|
let _ = config::save(cfg);
|
|
return;
|
|
}
|
|
};
|
|
|
|
eprintln!(
|
|
"Measured {:.2} Mbps up (safe estimate {:.2} Mbps, took {:.1}s).",
|
|
measurement.raw_mbps,
|
|
measurement.safe_mbps,
|
|
measurement.elapsed.as_secs_f64()
|
|
);
|
|
cfg.bandwidth = config::BandwidthEntry {
|
|
status: config::BandwidthStatus::Measured,
|
|
upstream_mbps: Some(measurement.safe_mbps),
|
|
measured_at: Some(chrono::Utc::now()),
|
|
};
|
|
if let Err(e) = config::save(cfg) {
|
|
eprintln!("Warning: failed to save result: {e:#}");
|
|
}
|
|
}
|
|
|
|
fn print_welcome() {
|
|
eprintln!();
|
|
eprintln!("Welcome to PixelPass.");
|
|
eprintln!();
|
|
}
|
|
|
|
fn prompt_ticket(theme: &ColorfulTheme) -> Result<EndpointTicket> {
|
|
loop {
|
|
let raw: String = Input::with_theme(theme)
|
|
.with_prompt("Paste the share code you received")
|
|
.interact_text()?;
|
|
match EndpointTicket::from_str(raw.trim()) {
|
|
Ok(t) => return Ok(t),
|
|
Err(_) => eprintln!("That doesn't look like a share code. Try again."),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Player options the interactive viewer can launch.
|
|
#[derive(Clone, Copy)]
|
|
pub enum Player {
|
|
Mpv,
|
|
Vlc,
|
|
}
|
|
|
|
impl Player {
|
|
pub fn spawn(self, url: &str) -> std::io::Result<()> {
|
|
match self {
|
|
Player::Mpv => crate::common::process::spawn_detached(
|
|
"mpv",
|
|
&[
|
|
// No `--untimed`: it ignores audio timestamps and drifts a
|
|
// shared video out of sync. Pacing to audio keeps A/V synced.
|
|
// Also leave hwdec at the `low-latency` default (software
|
|
// decode): forcing `--hwdec=auto` froze some viewers on
|
|
// frame 1 while audio kept playing.
|
|
"--profile=low-latency",
|
|
"--audio-buffer=0.2",
|
|
"--demuxer-max-bytes=2M",
|
|
"--demuxer-readahead-secs=0.5",
|
|
url,
|
|
],
|
|
),
|
|
Player::Vlc => {
|
|
warn_if_vlc_plugins_missing();
|
|
crate::common::process::spawn_detached(
|
|
"vlc",
|
|
&["--network-caching=200", "--live-caching=200", url],
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// On Arch-family distros, the base `vlc` package omits two plugins
|
|
// pixelpass needs: the MPEG-TS demuxer (`vlc-plugin-dvb`) and the
|
|
// libavcodec-based H.264 decoder (`vlc-plugin-ffmpeg`). Missing either
|
|
// produces a confusing error chain — warn at launch.
|
|
fn warn_if_vlc_plugins_missing() {
|
|
const REQUIRED: &[(&str, &str)] = &[
|
|
(
|
|
"/usr/lib/vlc/plugins/demux/libts_plugin.so",
|
|
"vlc-plugin-dvb",
|
|
),
|
|
(
|
|
"/usr/lib/vlc/plugins/codec/libavcodec_plugin.so",
|
|
"vlc-plugin-ffmpeg",
|
|
),
|
|
];
|
|
let missing: Vec<&(&str, &str)> = REQUIRED
|
|
.iter()
|
|
.filter(|(p, _)| !std::path::Path::new(p).exists())
|
|
.collect();
|
|
if missing.is_empty() {
|
|
return;
|
|
}
|
|
eprintln!();
|
|
eprintln!("Warning: VLC is missing plugins pixelpass needs:");
|
|
for (path, pkg) in &missing {
|
|
eprintln!(" - {path} (install `{pkg}`)");
|
|
}
|
|
eprintln!("On Arch / CachyOS / EndeavourOS: `sudo pacman -S {}`.", {
|
|
let names: Vec<&str> = missing.iter().map(|(_, p)| *p).collect();
|
|
names.join(" ")
|
|
});
|
|
eprintln!("mpv is unaffected.");
|
|
eprintln!();
|
|
}
|
|
|
|
pub fn prompt_player() -> Result<Player> {
|
|
let theme = ColorfulTheme::default();
|
|
let choice = Select::with_theme(&theme)
|
|
.with_prompt("Connected. Pick a player to launch")
|
|
.items(["mpv", "VLC"])
|
|
.default(0)
|
|
.interact()?;
|
|
Ok(if choice == 0 {
|
|
Player::Mpv
|
|
} else {
|
|
Player::Vlc
|
|
})
|
|
}
|