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
+56
View File
@@ -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")