2026-06-22 00:19:38 +02:00
|
|
|
using VoiceCat.App.Audio;
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.App.Forms;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Modal dialog for selecting which apps' audio to share.
|
|
|
|
|
/// Returns the chosen <see cref="AppAudioScope"/> via <see cref="ChosenScope"/>,
|
|
|
|
|
/// or <see cref="DialogResult.Cancel"/> if the user dismissed without sharing.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class AppAudioPickerDialog : Form
|
|
|
|
|
{
|
|
|
|
|
private readonly RadioButton _rdoAll;
|
|
|
|
|
private readonly RadioButton _rdoOnly;
|
|
|
|
|
private readonly RadioButton _rdoExcept;
|
2026-06-24 00:24:20 +02:00
|
|
|
private readonly CheckBox _chkExcludeSelf;
|
|
|
|
|
private readonly TextBox _txtFilter;
|
2026-06-22 00:19:38 +02:00
|
|
|
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 = [];
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
// 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();
|
|
|
|
|
|
2026-06-22 00:19:38 +02:00
|
|
|
public AppAudioScope? ChosenScope { get; private set; }
|
|
|
|
|
|
|
|
|
|
public AppAudioPickerDialog()
|
|
|
|
|
{
|
|
|
|
|
// ── Radio buttons ───────────────────────────────────────────────────
|
|
|
|
|
_rdoAll = new RadioButton
|
|
|
|
|
{
|
|
|
|
|
Text = "&Entire desktop",
|
|
|
|
|
Checked = true,
|
|
|
|
|
Location = new Point(12, 12),
|
|
|
|
|
Size = new Size(360, 20),
|
|
|
|
|
TabIndex = 0,
|
|
|
|
|
};
|
|
|
|
|
_rdoOnly = new RadioButton
|
|
|
|
|
{
|
|
|
|
|
Text = "&Only selected apps",
|
|
|
|
|
Location = new Point(12, 36),
|
|
|
|
|
Size = new Size(360, 20),
|
|
|
|
|
TabIndex = 1,
|
|
|
|
|
};
|
|
|
|
|
_rdoExcept = new RadioButton
|
|
|
|
|
{
|
|
|
|
|
Text = "All apps e&xcept selected",
|
|
|
|
|
Location = new Point(12, 60),
|
|
|
|
|
Size = new Size(360, 20),
|
|
|
|
|
TabIndex = 2,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_rdoAll.CheckedChanged += OnModeChanged;
|
|
|
|
|
_rdoOnly.CheckedChanged += OnModeChanged;
|
|
|
|
|
_rdoExcept.CheckedChanged += OnModeChanged;
|
|
|
|
|
|
2026-06-22 12:28:53 +02:00
|
|
|
// ── Self-exclude (echo prevention) ───────────────────────────────────
|
|
|
|
|
// Captures the whole desktop minus VoiceCat's own playback, so the channel
|
|
|
|
|
// doesn't echo back the voices you're already hearing. Only applies to the
|
|
|
|
|
// entire-desktop mode (the per-app modes already exclude this app's tree).
|
|
|
|
|
_chkExcludeSelf = new CheckBox
|
|
|
|
|
{
|
|
|
|
|
Text = "E&xclude VoiceCat's own audio (prevents echo)",
|
|
|
|
|
Checked = true,
|
|
|
|
|
Location = new Point(28, 84),
|
|
|
|
|
Size = new Size(344, 20),
|
|
|
|
|
TabIndex = 3,
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-22 00:19:38 +02:00
|
|
|
// ── App list ────────────────────────────────────────────────────────
|
|
|
|
|
_lblApps = new Label
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
Text = "Apps (▶ = currently playing):",
|
2026-06-22 00:19:38 +02:00
|
|
|
AutoSize = true,
|
2026-06-22 12:28:53 +02:00
|
|
|
Location = new Point(12, 112),
|
2026-06-22 00:19:38 +02:00
|
|
|
Visible = false,
|
2026-06-22 12:28:53 +02:00
|
|
|
TabIndex = 4,
|
2026-06-22 00:19:38 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
_txtFilter = new TextBox
|
|
|
|
|
{
|
|
|
|
|
Location = new Point(12, 132),
|
|
|
|
|
Size = new Size(360, 23),
|
|
|
|
|
PlaceholderText = "Filter apps…",
|
|
|
|
|
Visible = false,
|
|
|
|
|
TabIndex = 5,
|
|
|
|
|
};
|
|
|
|
|
_txtFilter.TextChanged += (_, _) => PopulateList();
|
|
|
|
|
|
2026-06-22 00:19:38 +02:00
|
|
|
_appList = new ListView
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
Location = new Point(12, 160),
|
|
|
|
|
Size = new Size(360, 150),
|
2026-06-22 00:19:38 +02:00
|
|
|
CheckBoxes = true,
|
|
|
|
|
View = View.List,
|
|
|
|
|
Visible = false,
|
2026-06-24 00:24:20 +02:00
|
|
|
TabIndex = 6,
|
2026-06-22 00:19:38 +02:00
|
|
|
FullRowSelect = true,
|
|
|
|
|
};
|
2026-06-22 12:28:53 +02:00
|
|
|
// Exclude mode supports only one target process tree (WASAPI EXCLUDE takes a single
|
|
|
|
|
// PID), so enforce a single check while "All apps except selected" is active.
|
|
|
|
|
_appList.ItemCheck += OnAppItemCheck;
|
2026-06-22 00:19:38 +02:00
|
|
|
|
|
|
|
|
// ── Buttons ─────────────────────────────────────────────────────────
|
|
|
|
|
var btnShare = new Button
|
|
|
|
|
{
|
|
|
|
|
Text = "&Share",
|
|
|
|
|
DialogResult = DialogResult.OK,
|
2026-06-24 00:24:20 +02:00
|
|
|
Location = new Point(216, 320),
|
2026-06-22 00:19:38 +02:00
|
|
|
Size = new Size(75, 27),
|
2026-06-24 00:24:20 +02:00
|
|
|
TabIndex = 7,
|
2026-06-22 00:19:38 +02:00
|
|
|
};
|
|
|
|
|
var btnCancel = new Button
|
|
|
|
|
{
|
|
|
|
|
Text = "&Cancel",
|
|
|
|
|
DialogResult = DialogResult.Cancel,
|
2026-06-24 00:24:20 +02:00
|
|
|
Location = new Point(297, 320),
|
2026-06-22 00:19:38 +02:00
|
|
|
Size = new Size(75, 27),
|
2026-06-24 00:24:20 +02:00
|
|
|
TabIndex = 8,
|
2026-06-22 00:19:38 +02:00
|
|
|
};
|
|
|
|
|
btnShare.Click += OnShareClick;
|
|
|
|
|
|
|
|
|
|
AcceptButton = btnShare;
|
|
|
|
|
CancelButton = btnCancel;
|
|
|
|
|
AutoScaleMode = AutoScaleMode.Font;
|
2026-06-24 00:24:20 +02:00
|
|
|
ClientSize = new Size(384, 360);
|
|
|
|
|
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _txtFilter,
|
|
|
|
|
_appList, btnShare, btnCancel]);
|
2026-06-22 00:19:38 +02:00
|
|
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
|
|
|
|
MaximizeBox = false;
|
|
|
|
|
MinimizeBox = false;
|
|
|
|
|
StartPosition = FormStartPosition.CenterParent;
|
|
|
|
|
Text = "Share App Audio";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override void OnLoad(EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
base.OnLoad(e);
|
|
|
|
|
RefreshAppList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void RefreshAppList()
|
|
|
|
|
{
|
|
|
|
|
_apps = AudioSessionEnumerator.GetAudioApps();
|
2026-06-24 00:24:20 +02:00
|
|
|
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();
|
2026-06-22 00:19:38 +02:00
|
|
|
_appList.Items.Clear();
|
|
|
|
|
foreach (var app in _apps)
|
2026-06-24 00:24:20 +02:00
|
|
|
{
|
|
|
|
|
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;
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnModeChanged(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
|
2026-06-24 00:24:20 +02:00
|
|
|
_lblApps.Visible = showList;
|
|
|
|
|
_txtFilter.Visible = showList;
|
|
|
|
|
_appList.Visible = showList;
|
2026-06-22 12:28:53 +02:00
|
|
|
_lblApps.Text = _rdoExcept.Checked
|
|
|
|
|
? "App to exclude (everything else is shared):"
|
2026-06-24 00:24:20 +02:00
|
|
|
: "Apps (▶ = currently playing):";
|
2026-06-22 12:28:53 +02:00
|
|
|
|
|
|
|
|
// 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;
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-22 12:28:53 +02:00
|
|
|
// Switching into exclude mode: collapse any multi-selection down to a single item.
|
|
|
|
|
if (_rdoExcept.Checked)
|
2026-06-24 00:24:20 +02:00
|
|
|
TrimCheckedToOne();
|
|
|
|
|
|
|
|
|
|
if (showList && _appList.Items.Count == 0)
|
|
|
|
|
RefreshAppList();
|
|
|
|
|
else
|
|
|
|
|
PopulateList(); // re-render markers/labels and restore check state
|
2026-06-22 12:28:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// In exclude mode the list behaves like radio buttons: checking one item clears the rest.
|
|
|
|
|
private bool _suppressItemCheck;
|
|
|
|
|
|
|
|
|
|
private void OnAppItemCheck(object? sender, ItemCheckEventArgs e)
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
if (_suppressItemCheck) return;
|
|
|
|
|
if (_appList.Items[e.Index].Tag is not AudioAppInfo app) return;
|
2026-06-22 12:28:53 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-22 12:28:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
// 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()
|
2026-06-22 12:28:53 +02:00
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
if (_checked.Count <= 1) return;
|
|
|
|
|
var first = _checked.First();
|
|
|
|
|
_checked.Clear();
|
|
|
|
|
_checked[first.Key] = first.Value;
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnShareClick(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (_rdoAll.Checked)
|
|
|
|
|
{
|
2026-06-22 12:28:53 +02:00
|
|
|
ChosenScope = new EntireDesktop(_chkExcludeSelf.Checked);
|
2026-06-22 00:19:38 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
if (_checked.Count == 0)
|
2026-06-22 00:19:38 +02:00
|
|
|
{
|
|
|
|
|
MessageBox.Show(
|
|
|
|
|
"Select at least one app, or choose 'Entire desktop'.",
|
|
|
|
|
"No apps selected",
|
|
|
|
|
MessageBoxButtons.OK,
|
|
|
|
|
MessageBoxIcon.Warning);
|
|
|
|
|
DialogResult = DialogResult.None; // prevent close
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
var pids = _checked.Keys.ToList();
|
|
|
|
|
var names = _checked.Values.ToList();
|
|
|
|
|
|
2026-06-22 00:19:38 +02:00
|
|
|
ChosenScope = _rdoOnly.Checked
|
2026-06-24 00:24:20 +02:00
|
|
|
? new OnlyApps(pids, names)
|
|
|
|
|
: new AllExceptApps(pids, names);
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
}
|