fix(windows): list all user-session apps in the app-audio picker
The picker gated its list on visible-window processes plus audio sessions on only the default render device. That both showed non-audio apps (any window) and missed real ones (windowless or routed to a secondary device). Process loopback targets a PID and its child tree regardless of whether the app is currently playing, so the gate fought the capture layer. Now enumerate every process in the user's interactive session (windowed or not), deduped by executable with the windowed tree-root as the capture PID; scan all active render endpoints to flag currently-playing apps with a > and sort them first. Adds a filter box and persists checks across filtering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,13 +12,18 @@ public sealed class AppAudioPickerDialog : Form
|
||||
private readonly RadioButton _rdoAll;
|
||||
private readonly RadioButton _rdoOnly;
|
||||
private readonly RadioButton _rdoExcept;
|
||||
private readonly CheckBox _chkExcludeSelf;
|
||||
private readonly CheckBox _chkExcludeSelf;
|
||||
private readonly TextBox _txtFilter;
|
||||
private readonly ListView _appList;
|
||||
private readonly Label _lblApps;
|
||||
|
||||
// Snapshot taken when the dialog opens (refresh on open, not on every check change).
|
||||
private IReadOnlyList<AudioAppInfo> _apps = [];
|
||||
|
||||
// Checked apps survive filtering (a filtered-out row keeps its check here).
|
||||
// pid → clean display name (used for the shared scope's Names).
|
||||
private readonly Dictionary<int, string> _checked = new();
|
||||
|
||||
public AppAudioScope? ChosenScope { get; private set; }
|
||||
|
||||
public AppAudioPickerDialog()
|
||||
@@ -67,21 +72,31 @@ public sealed class AppAudioPickerDialog : Form
|
||||
// ── App list ────────────────────────────────────────────────────────
|
||||
_lblApps = new Label
|
||||
{
|
||||
Text = "Apps with active audio sessions:",
|
||||
Text = "Apps (▶ = currently playing):",
|
||||
AutoSize = true,
|
||||
Location = new Point(12, 112),
|
||||
Visible = false,
|
||||
TabIndex = 4,
|
||||
};
|
||||
|
||||
_txtFilter = new TextBox
|
||||
{
|
||||
Location = new Point(12, 132),
|
||||
Size = new Size(360, 23),
|
||||
PlaceholderText = "Filter apps…",
|
||||
Visible = false,
|
||||
TabIndex = 5,
|
||||
};
|
||||
_txtFilter.TextChanged += (_, _) => PopulateList();
|
||||
|
||||
_appList = new ListView
|
||||
{
|
||||
Location = new Point(12, 134),
|
||||
Size = new Size(360, 158),
|
||||
Location = new Point(12, 160),
|
||||
Size = new Size(360, 150),
|
||||
CheckBoxes = true,
|
||||
View = View.List,
|
||||
Visible = false,
|
||||
TabIndex = 5,
|
||||
TabIndex = 6,
|
||||
FullRowSelect = true,
|
||||
};
|
||||
// Exclude mode supports only one target process tree (WASAPI EXCLUDE takes a single
|
||||
@@ -93,26 +108,26 @@ public sealed class AppAudioPickerDialog : Form
|
||||
{
|
||||
Text = "&Share",
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(216, 308),
|
||||
Location = new Point(216, 320),
|
||||
Size = new Size(75, 27),
|
||||
TabIndex = 6,
|
||||
TabIndex = 7,
|
||||
};
|
||||
var btnCancel = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
DialogResult = DialogResult.Cancel,
|
||||
Location = new Point(297, 308),
|
||||
Location = new Point(297, 320),
|
||||
Size = new Size(75, 27),
|
||||
TabIndex = 7,
|
||||
TabIndex = 8,
|
||||
};
|
||||
btnShare.Click += OnShareClick;
|
||||
|
||||
AcceptButton = btnShare;
|
||||
CancelButton = btnCancel;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(384, 348);
|
||||
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _appList,
|
||||
btnShare, btnCancel]);
|
||||
ClientSize = new Size(384, 360);
|
||||
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _txtFilter,
|
||||
_appList, btnShare, btnCancel]);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
@@ -129,30 +144,57 @@ public sealed class AppAudioPickerDialog : Form
|
||||
private void RefreshAppList()
|
||||
{
|
||||
_apps = AudioSessionEnumerator.GetAudioApps();
|
||||
PopulateList();
|
||||
}
|
||||
|
||||
// Rebuild the visible rows from _apps + the current filter text, restoring check
|
||||
// state from _checked so a selection persists while the user filters.
|
||||
private void PopulateList()
|
||||
{
|
||||
string filter = _txtFilter.Text.Trim();
|
||||
|
||||
_suppressItemCheck = true;
|
||||
_appList.BeginUpdate();
|
||||
_appList.Items.Clear();
|
||||
foreach (var app in _apps)
|
||||
_appList.Items.Add(new ListViewItem($"{app.DisplayName} (PID {app.Pid})") { Tag = app.Pid });
|
||||
{
|
||||
if (filter.Length > 0 &&
|
||||
app.DisplayName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
|
||||
string marker = app.IsPlaying ? "▶ " : "";
|
||||
_appList.Items.Add(new ListViewItem($"{marker}{app.DisplayName} (PID {app.Pid})")
|
||||
{
|
||||
Tag = app,
|
||||
Checked = _checked.ContainsKey(app.Pid),
|
||||
});
|
||||
}
|
||||
_appList.EndUpdate();
|
||||
_suppressItemCheck = false;
|
||||
}
|
||||
|
||||
private void OnModeChanged(object? sender, EventArgs e)
|
||||
{
|
||||
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
|
||||
_lblApps.Visible = showList;
|
||||
_appList.Visible = showList;
|
||||
_lblApps.Visible = showList;
|
||||
_txtFilter.Visible = showList;
|
||||
_appList.Visible = showList;
|
||||
_lblApps.Text = _rdoExcept.Checked
|
||||
? "App to exclude (everything else is shared):"
|
||||
: "Apps with active audio sessions:";
|
||||
: "Apps (▶ = currently playing):";
|
||||
|
||||
// Self-exclude only applies to entire-desktop; the per-app modes already exclude
|
||||
// this app's own tree (Include) or spend the single EXCLUDE slot on the chosen app.
|
||||
_chkExcludeSelf.Enabled = _rdoAll.Checked;
|
||||
|
||||
if (showList && _appList.Items.Count == 0)
|
||||
RefreshAppList();
|
||||
|
||||
// Switching into exclude mode: collapse any multi-selection down to a single item.
|
||||
if (_rdoExcept.Checked)
|
||||
KeepSingleCheck(firstCheckedOnly: true);
|
||||
TrimCheckedToOne();
|
||||
|
||||
if (showList && _appList.Items.Count == 0)
|
||||
RefreshAppList();
|
||||
else
|
||||
PopulateList(); // re-render markers/labels and restore check state
|
||||
}
|
||||
|
||||
// In exclude mode the list behaves like radio buttons: checking one item clears the rest.
|
||||
@@ -160,28 +202,36 @@ public sealed class AppAudioPickerDialog : Form
|
||||
|
||||
private void OnAppItemCheck(object? sender, ItemCheckEventArgs e)
|
||||
{
|
||||
if (_suppressItemCheck || !_rdoExcept.Checked || e.NewValue != CheckState.Checked)
|
||||
return;
|
||||
if (_suppressItemCheck) return;
|
||||
if (_appList.Items[e.Index].Tag is not AudioAppInfo app) return;
|
||||
|
||||
_suppressItemCheck = true;
|
||||
foreach (ListViewItem item in _appList.Items)
|
||||
if (item.Index != e.Index && item.Checked)
|
||||
item.Checked = false;
|
||||
_suppressItemCheck = false;
|
||||
if (e.NewValue == CheckState.Checked)
|
||||
{
|
||||
if (_rdoExcept.Checked)
|
||||
{
|
||||
// Radio behavior: clear every other visible check and the persisted set.
|
||||
_suppressItemCheck = true;
|
||||
foreach (ListViewItem item in _appList.Items)
|
||||
if (item.Index != e.Index && item.Checked) item.Checked = false;
|
||||
_suppressItemCheck = false;
|
||||
_checked.Clear();
|
||||
}
|
||||
_checked[app.Pid] = app.DisplayName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_checked.Remove(app.Pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Leave at most one item checked (the first), unchecking the rest.
|
||||
private void KeepSingleCheck(bool firstCheckedOnly)
|
||||
// Reduce the persisted selection to a single (first) entry — used when entering
|
||||
// exclude mode, whose single EXCLUDE slot can only target one process tree.
|
||||
private void TrimCheckedToOne()
|
||||
{
|
||||
_suppressItemCheck = true;
|
||||
bool kept = false;
|
||||
foreach (ListViewItem item in _appList.Items)
|
||||
{
|
||||
if (!item.Checked) continue;
|
||||
if (firstCheckedOnly && !kept) { kept = true; continue; }
|
||||
item.Checked = false;
|
||||
}
|
||||
_suppressItemCheck = false;
|
||||
if (_checked.Count <= 1) return;
|
||||
var first = _checked.First();
|
||||
_checked.Clear();
|
||||
_checked[first.Key] = first.Value;
|
||||
}
|
||||
|
||||
private void OnShareClick(object? sender, EventArgs e)
|
||||
@@ -192,18 +242,7 @@ public sealed class AppAudioPickerDialog : Form
|
||||
return;
|
||||
}
|
||||
|
||||
var checkedPids = new List<int>();
|
||||
var checkedNames = new List<string>();
|
||||
foreach (ListViewItem item in _appList.CheckedItems)
|
||||
{
|
||||
if (item.Tag is int pid)
|
||||
{
|
||||
checkedPids.Add(pid);
|
||||
checkedNames.Add(item.Text);
|
||||
}
|
||||
}
|
||||
|
||||
if (checkedPids.Count == 0)
|
||||
if (_checked.Count == 0)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Select at least one app, or choose 'Entire desktop'.",
|
||||
@@ -214,8 +253,11 @@ public sealed class AppAudioPickerDialog : Form
|
||||
return;
|
||||
}
|
||||
|
||||
var pids = _checked.Keys.ToList();
|
||||
var names = _checked.Values.ToList();
|
||||
|
||||
ChosenScope = _rdoOnly.Checked
|
||||
? new OnlyApps(checkedPids, checkedNames)
|
||||
: new AllExceptApps(checkedPids, checkedNames);
|
||||
? new OnlyApps(pids, names)
|
||||
: new AllExceptApps(pids, names);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user