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:
@@ -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<Vec<App>> {
|
||||
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<Vec<App>> {
|
||||
let entries: Vec<SinkInput> =
|
||||
serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?;
|
||||
let mut counts: BTreeMap<String, u32> = 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<String>,
|
||||
}
|
||||
|
||||
fn load_module(args: &[&str]) -> Result<u32> {
|
||||
let output = Command::new("pactl")
|
||||
.arg("load-module")
|
||||
|
||||
+5
-2
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user