diff --git a/src/host/audio.rs b/src/host/audio.rs index 2e5b35a..35a51d2 100644 --- a/src/host/audio.rs +++ b/src/host/audio.rs @@ -15,6 +15,7 @@ //! needs it for stream events. use anyhow::{Context, Result, bail}; +use std::collections::BTreeMap; use std::process::Command; /// Owns the pactl module IDs for the null-sink and its loopback. Drop @@ -92,6 +93,61 @@ impl Drop for Routing { } } +/// One deduplicated app currently producing audio. The picker in +/// interactive mode shows these as the per-app capture choices. +#[derive(Debug, Clone)] +pub struct App { + pub name: String, + pub stream_count: u32, +} + +/// Enumerate apps currently sending audio to any sink, deduplicated by +/// `application.name`. Returns an empty Vec if nothing is playing. +/// Per-stream routing isn't wired yet — session 2 just plumbs the +/// selection through `HostOpts.app`; routing lands in session 3. +pub fn list_playing_apps() -> Result> { + let output = Command::new("pactl") + .args(["-f", "json", "list", "sink-inputs"]) + .output() + .context("failed to run `pactl -f json list sink-inputs`")?; + if !output.status.success() { + bail!( + "pactl list sink-inputs failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + parse_sink_inputs(&output.stdout) +} + +fn parse_sink_inputs(stdout: &[u8]) -> Result> { + let entries: Vec = + serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?; + let mut counts: BTreeMap = BTreeMap::new(); + for entry in entries { + let Some(name) = entry.properties.application_name else { continue }; + let trimmed = name.trim(); + if trimmed.is_empty() { + continue; + } + *counts.entry(trimmed.to_string()).or_insert(0) += 1; + } + Ok(counts + .into_iter() + .map(|(name, stream_count)| App { name, stream_count }) + .collect()) +} + +#[derive(serde::Deserialize)] +struct SinkInput { + properties: SinkInputProperties, +} + +#[derive(serde::Deserialize)] +struct SinkInputProperties { + #[serde(rename = "application.name")] + application_name: Option, +} + fn load_module(args: &[&str]) -> Result { let output = Command::new("pactl") .arg("load-module") diff --git a/src/host/mod.rs b/src/host/mod.rs index 0a02665..653caad 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -1,4 +1,4 @@ -mod audio; +pub mod audio; mod capture; mod serve; mod wayland; @@ -317,8 +317,11 @@ fn copy_to_clipboard(text: &str) -> bool { fn capture_summary(opts: &HostOpts) -> String { let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; + // Per-stream routing isn't wired yet (session 3 owns activation), so + // even when an app is selected the audio capture is system-wide. Keep + // the choice visible in the banner but label it honestly. if let Some(app) = &opts.app { - bits.push(format!("app-audio={app}")); + bits.push(format!("system-audio (app pick saved: {app})")); } else { bits.push("system-audio".to_string()); } diff --git a/src/interactive.rs b/src/interactive.rs index 56a6149..16b00cc 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -23,6 +23,10 @@ pub async fn run(cli: Cli) -> Result<()> { match choice { 0 => { preflight_if_needed(&theme).await; + let mut cli = cli; + if cli.app.is_none() { + cli.app = pick_app(&theme)?; + } host::run(cli.into_host_opts(true)).await } _ => { @@ -32,6 +36,57 @@ pub async fn run(cli: Cli) -> Result<()> { } } +/// 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. +/// +/// Per-stream routing isn't wired yet — session 3 owns activation. For +/// now the picker plumbs the choice into `HostOpts.app` and the host +/// banner records it, but audio capture is still system-wide. The +/// explainer beneath the prompt sets that expectation honestly. +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!("Note: per-app routing isn't live yet — all choices currently capture"); + eprintln!("system audio. The selection is saved for when routing ships."); + 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())) + } +} + /// `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).