host/audio: app enumeration + interactive picker (session 2 of 4)

list_playing_apps() shells out to `pactl -f json list sink-inputs`,
parses with serde_json, dedupes by application.name (BTreeMap for
stable ordering), returns Vec<App { name, stream_count }>.

Picker fires in interactive::run after preflight, before host::run.
Bypassed when --app NAME is on the CLI. Shows the apps with a
"per-app routing isn't live yet" explainer so users aren't surprised
that audio still captures system-wide. Empty-list path shows the
default + a "start your app first" hint so the feature stays
discoverable.

Banner softened to `system-audio (app pick saved: <name>)` when
opts.app is set — keeps the choice visible without lying about what
gets captured. Routing activation still gated on the
PIXELPASS_AUDIO_VIA_NULL_SINK env var (session 1's locked decision
#2); --app flips to that activation in session 3 once per-stream
filtering exists.

Verified end-to-end interactively: Strawberry shows up in the picker
during music playback, both default and app-pick paths advance into
the portal handshake, banner matches choice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 15:32:00 -04:00
parent 8d32ded412
commit 339a9d49e4
3 changed files with 116 additions and 2 deletions
+55
View File
@@ -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<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!("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).