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> { 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 { 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 { 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 { 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 }) }