Files
pixelpass/src/interactive.rs
T
mollusk b6240c17c5 viewer: drop forced --hwdec=auto (froze video on frame 1)
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>
2026-07-04 21:15:11 -04:00

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