refactor(viewmodels): split MainViewModel into themed partial classes
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation loops, Teams UIA plumbing, and the auto-apply-last-preset state machine sitting on top of the actual MainViewModel surface (constructor, props, OnStatsTick). Splits the class via partial-class into themed siblings: * MainViewModel.cs (was 1017L → now 699L) — fields, properties, constructor that wires every Command, OnStatsTick + Dispose. This remains the thin aggregator. * MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper, JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle (already-tested static), PollTeamsMeetingState (the 1Hz UIA probe formerly inlined in OnStatsTick). * MainViewModel.PresetCommands.cs (108L, new) — RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences (called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile step), and the _pendingPreset* private-field set that backs the path. * MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync, StopAllIsosAsync (with the default-No confirmation dialog), SnapshotAll. RecordingCommands.cs from the original punch list is intentionally absent — the recording surface was axed at 1d1ce6a; what remains here is bulk-state ops across the participants collection (note in the file header). Why partial-class instead of helper-services or composed objects: every extracted method touches the same private dispatcher / controller / participants / toast state. Composing would require either passing those references in (verbose call sites) or extracting them to a shared private context object (boilerplate). Partial gives us file-level separation without spreading the state contract. ExtractMeetingTitle stays internal-static so the existing MeetingTitleExtractionTests (10 cases) keep finding it. Build clean; 56 App + 104 Engine tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33fca8e955
commit
d02a2c059b
4 changed files with 414 additions and 345 deletions
149
src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs
Normal file
149
src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
// Bulk operations that touch every (or every-enabled) participant —
|
||||||
|
// Stop all ISOs, Enable all online, Snapshot all enabled frames.
|
||||||
|
// Split out of MainViewModel.cs so the main file isn't dominated by
|
||||||
|
// long async iteration loops.
|
||||||
|
//
|
||||||
|
// The RecordingCommands partial originally planned at this slot is
|
||||||
|
// intentionally absent: the recording surface was axed earlier in the
|
||||||
|
// May 2026 batch (see commit 1d1ce6a). What remains is bulk-state
|
||||||
|
// manipulation across the participants collection.
|
||||||
|
public sealed partial class MainViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enable ISOs for every online + non-enabled participant in
|
||||||
|
/// parallel-ish (sequential await, but each individual EnableIsoAsync
|
||||||
|
/// is fast). Tolerates per-participant failures so one bad source
|
||||||
|
/// doesn't abort the rest.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnableAllOnlineAsync()
|
||||||
|
{
|
||||||
|
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
|
||||||
|
var enabled = 0;
|
||||||
|
foreach (var p in candidates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
|
||||||
|
? OutputNameTemplate.Render(
|
||||||
|
OutputNameTemplate.Get(),
|
||||||
|
p.Id,
|
||||||
|
p.DisplayName)
|
||||||
|
: p.CustomName;
|
||||||
|
// 3-arg overload (no recordOverride) — recording surface axed,
|
||||||
|
// so the engine's per-pipeline recorder sink stays unattached.
|
||||||
|
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
|
||||||
|
p.IsEnabled = true;
|
||||||
|
enabled++;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Per-participant best-effort — one bad source shouldn't
|
||||||
|
// abort the bulk operation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toast.Show(enabled == 0
|
||||||
|
? "No participants to enable"
|
||||||
|
: $"Enabled {enabled} ISO(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emergency-stop: disable every running ISO. Confirmation dialog with
|
||||||
|
/// default-No guards mid-show misclicks; the regret cost of yanking 5
|
||||||
|
/// ISOs is far higher than the Enter-press cost of the prompt.
|
||||||
|
/// </summary>
|
||||||
|
private async Task StopAllIsosAsync()
|
||||||
|
{
|
||||||
|
// Snapshot first so the collection doesn't mutate while we iterate.
|
||||||
|
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
||||||
|
if (enabled.Length == 0)
|
||||||
|
{
|
||||||
|
Toast.Show("No ISOs to stop");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var confirm = System.Windows.MessageBox.Show(
|
||||||
|
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
|
||||||
|
"TeamsISO — Stop all ISOs",
|
||||||
|
System.Windows.MessageBoxButton.YesNo,
|
||||||
|
System.Windows.MessageBoxImage.Warning,
|
||||||
|
System.Windows.MessageBoxResult.No);
|
||||||
|
if (confirm != System.Windows.MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
foreach (var p in enabled)
|
||||||
|
{
|
||||||
|
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
||||||
|
catch { /* defensive */ }
|
||||||
|
p.IsEnabled = false;
|
||||||
|
}
|
||||||
|
Toast.Show($"Stopped {enabled.Length} ISO(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save a PNG of every currently-enabled participant's latest
|
||||||
|
/// processed frame to a timestamped subdirectory under
|
||||||
|
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
|
||||||
|
/// so back-to-back clicks don't comingle. Useful for end-of-meeting
|
||||||
|
/// archives, recapping who showed up, etc.
|
||||||
|
/// </summary>
|
||||||
|
private void SnapshotAll()
|
||||||
|
{
|
||||||
|
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
||||||
|
if (enabled.Length == 0)
|
||||||
|
{
|
||||||
|
Toast.Warn("No enabled participants to snapshot");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootDir = System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
|
||||||
|
"TeamsISO",
|
||||||
|
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(rootDir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = 0;
|
||||||
|
var failed = 0;
|
||||||
|
foreach (var p in enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var frame = _controller.GetLatestProcessedFrame(p.Id);
|
||||||
|
if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }
|
||||||
|
|
||||||
|
var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
|
||||||
|
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
|
||||||
|
var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");
|
||||||
|
|
||||||
|
var stride = frame.Width * 4;
|
||||||
|
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||||
|
frame.Width, frame.Height, 96, 96,
|
||||||
|
System.Windows.Media.PixelFormats.Bgra32, null);
|
||||||
|
bmp.WritePixels(
|
||||||
|
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
|
||||||
|
frame.Pixels.ToArray(), stride, 0);
|
||||||
|
|
||||||
|
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
|
||||||
|
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
|
||||||
|
encoder.Save(fs);
|
||||||
|
saved++;
|
||||||
|
}
|
||||||
|
catch { failed++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.Show(failed > 0
|
||||||
|
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
|
||||||
|
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs
Normal file
108
src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
// Auto-apply-last-preset path. Split out of MainViewModel.cs so the
|
||||||
|
// pending-preset bookkeeping doesn't clutter the main file.
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// • InitializeAsync (in main file) reads operator preference + last-applied
|
||||||
|
// name from OperatorPresetStore and sets _pendingPresetName + deadline.
|
||||||
|
// • OnParticipantsChanged (in main file) calls TryAutoApplyPendingPreset
|
||||||
|
// once participants populate.
|
||||||
|
// • RequestApplyPresetOnStartup is the CLI hook: `--apply-preset NAME`.
|
||||||
|
public sealed partial class MainViewModel
|
||||||
|
{
|
||||||
|
// Set on InitializeAsync from disk; cleared once we successfully apply
|
||||||
|
// (so we don't re-apply when the participant list later mutates). The
|
||||||
|
// grace deadline gives Teams enough time to publish all initial sources
|
||||||
|
// after engine start before we attempt the apply — applying before
|
||||||
|
// everyone's visible would partially-restore the routing and silently
|
||||||
|
// drop assignments for late-appearing participants.
|
||||||
|
private string? _pendingPresetName;
|
||||||
|
private DateTimeOffset _pendingPresetDeadline;
|
||||||
|
private bool _pendingPresetApplied;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
|
||||||
|
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
|
||||||
|
/// that the user-toggled auto-apply path uses, so a single trigger flow
|
||||||
|
/// covers both. Wins over the persisted preference (operator's CLI intent
|
||||||
|
/// is more recent than what's on disk).
|
||||||
|
/// </summary>
|
||||||
|
public void RequestApplyPresetOnStartup(string presetName)
|
||||||
|
{
|
||||||
|
_pendingPresetName = presetName;
|
||||||
|
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||||
|
_pendingPresetApplied = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the operator's auto-apply preference + last-applied preset name
|
||||||
|
/// from disk and seeds the pending-preset state. Called by InitializeAsync
|
||||||
|
/// during engine startup. Failures are swallowed — a preset read fault
|
||||||
|
/// should never block the engine from coming up.
|
||||||
|
/// </summary>
|
||||||
|
private void LoadPendingPresetFromPreferences()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pref = OperatorPresetStore.GetStartupPreference();
|
||||||
|
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
|
||||||
|
{
|
||||||
|
_pendingPresetName = pref.LastAppliedName;
|
||||||
|
// 30s grace window is generous: Teams typically advertises all
|
||||||
|
// existing participants within 5–10s of NDI discovery starting.
|
||||||
|
// After this deadline we apply with whoever is visible.
|
||||||
|
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* preset read failures shouldn't block engine startup */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to apply <c>_pendingPresetName</c> if either every preset
|
||||||
|
/// assignment matches a live participant, or the grace deadline has
|
||||||
|
/// passed. Idempotent — repeat calls without state change are no-ops;
|
||||||
|
/// once we fire we flag <c>_pendingPresetApplied</c> so subsequent
|
||||||
|
/// participant churn doesn't trigger a second apply. Failures (missing
|
||||||
|
/// preset on disk, preset that no longer matches anyone) are swallowed:
|
||||||
|
/// the operator can always re-apply manually via the dialog. Delegates
|
||||||
|
/// to <see cref="PresetApplier.ApplyAsync"/> for the actual
|
||||||
|
/// reconciliation so the dialog, REST surface, and this auto-apply path
|
||||||
|
/// all share a single implementation.
|
||||||
|
/// </summary>
|
||||||
|
private void TryAutoApplyPendingPreset()
|
||||||
|
{
|
||||||
|
OperatorPresetStore.Preset? preset;
|
||||||
|
try { preset = OperatorPresetStore.Find(_pendingPresetName!); }
|
||||||
|
catch { preset = null; }
|
||||||
|
if (preset is null)
|
||||||
|
{
|
||||||
|
_pendingPresetApplied = true; // give up; nothing on disk to apply
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var liveNames = new HashSet<string>(
|
||||||
|
Participants.Select(p => p.DisplayName),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
|
||||||
|
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
|
||||||
|
return; // wait for the rest of the meeting to populate
|
||||||
|
|
||||||
|
_pendingPresetApplied = true;
|
||||||
|
var captured = preset;
|
||||||
|
// Snapshot the participants list since we're about to await on a
|
||||||
|
// worker thread; the live ObservableCollection isn't safe to
|
||||||
|
// enumerate from outside the dispatcher.
|
||||||
|
var snapshot = Participants.ToList();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var result = await PresetApplier.ApplyAsync(
|
||||||
|
captured, snapshot, _controller, _dispatcher);
|
||||||
|
await _dispatcher.InvokeAsync(() =>
|
||||||
|
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
Normal file
130
src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
// Teams launch / in-call / join-by-URL command helpers — split out of
|
||||||
|
// MainViewModel.cs so the body methods don't live alongside the
|
||||||
|
// constructor wiring + reactive subscriptions. The four command
|
||||||
|
// PROPERTIES are declared back in MainViewModel.cs (public API surface);
|
||||||
|
// this file holds the helpers they invoke.
|
||||||
|
public sealed partial class MainViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand
|
||||||
|
/// that translates the result to a user-visible toast. Centralizes the
|
||||||
|
/// toast wording so the four control commands stay consistent.
|
||||||
|
/// </summary>
|
||||||
|
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
|
||||||
|
{
|
||||||
|
return new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
switch (invoke())
|
||||||
|
{
|
||||||
|
case TeamsControlBridge.InvokeResult.Invoked:
|
||||||
|
Toast.Show(successMessage);
|
||||||
|
break;
|
||||||
|
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
|
||||||
|
Toast.Warn("Teams isn't running.");
|
||||||
|
break;
|
||||||
|
case TeamsControlBridge.InvokeResult.ControlNotFound:
|
||||||
|
Toast.Warn($"{label} control not found — are you in a call?");
|
||||||
|
break;
|
||||||
|
case TeamsControlBridge.InvokeResult.InvokeFailed:
|
||||||
|
Toast.Warn($"{label} button found but disabled.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Body of <c>JoinMeetingCommand</c>. Trims the pasted URL, hands it to
|
||||||
|
/// <see cref="TeamsLauncher.TryJoinMeeting"/>, and runs the auto-hide
|
||||||
|
/// follow-up if the operator has that preference set.
|
||||||
|
/// </summary>
|
||||||
|
private void JoinPastedMeeting()
|
||||||
|
{
|
||||||
|
var url = (_joinMeetingUrl ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(url)) return;
|
||||||
|
if (TeamsLauncher.TryJoinMeeting(url, out var error))
|
||||||
|
{
|
||||||
|
Toast.Show("Joining Teams meeting…");
|
||||||
|
JoinMeetingUrl = string.Empty;
|
||||||
|
if (Settings.AutoHideTeamsWindows)
|
||||||
|
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Toast.Warn($"Could not join: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pull the meaningful "meeting title" out of Teams' raw window title.
|
||||||
|
/// Teams uses formats like:
|
||||||
|
/// "Weekly Standup | Microsoft Teams"
|
||||||
|
/// "Meeting with Alice | Microsoft Teams"
|
||||||
|
/// "Microsoft Teams" (no meeting, just the app)
|
||||||
|
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
|
||||||
|
/// short and readable. Truncate beyond 50 chars so a long meeting
|
||||||
|
/// subject doesn't push the rest of the IN-CALL bar off screen.
|
||||||
|
/// </summary>
|
||||||
|
internal static string ExtractMeetingTitle(string windowTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
|
||||||
|
var t = windowTitle.Trim();
|
||||||
|
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
|
||||||
|
{
|
||||||
|
var idx = t.IndexOf(sep, StringComparison.Ordinal);
|
||||||
|
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
|
||||||
|
}
|
||||||
|
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
||||||
|
if (t.Length > 50) t = t.Substring(0, 47) + "…";
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Meeting-state probe — runs on every 1Hz stats tick. We fire the UIA
|
||||||
|
/// traversal on a worker thread because it can take 50–200ms in a busy
|
||||||
|
/// call; the result is marshalled back to the dispatcher to update the
|
||||||
|
/// view-model properties. One-tick latency on the displayed state is
|
||||||
|
/// preferable to a UI hiccup.
|
||||||
|
/// </summary>
|
||||||
|
private void PollTeamsMeetingState()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var teamsRunning = TeamsLauncher.IsRunning();
|
||||||
|
if (!teamsRunning)
|
||||||
|
{
|
||||||
|
TeamsMeetingState = string.Empty;
|
||||||
|
IsTeamsInCall = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Single UIA traversal returns all three signals (in-call /
|
||||||
|
// muted / camera-off) so we don't pay for three walks of
|
||||||
|
// the same descendant tree at 1Hz.
|
||||||
|
var snap = TeamsControlBridge.DetectCallState();
|
||||||
|
var inCall = snap.IsInCall;
|
||||||
|
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
|
||||||
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
IsTeamsInCall = inCall;
|
||||||
|
TeamsMeetingState = inCall
|
||||||
|
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
|
||||||
|
: "READY";
|
||||||
|
IsLocalMuted = inCall && (snap.IsMuted ?? false);
|
||||||
|
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* UIA flakiness shouldn't crash the stats tick */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* defensive — probe failures must never break the tick */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,14 @@ namespace TeamsISO.App.ViewModels;
|
||||||
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
|
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
|
||||||
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
|
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
|
||||||
/// and marshals updates onto the UI dispatcher.
|
/// and marshals updates onto the UI dispatcher.
|
||||||
|
///
|
||||||
|
/// Split across partial files by responsibility:
|
||||||
|
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
|
||||||
|
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
|
||||||
|
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
|
||||||
|
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MainViewModel : ObservableObject, IDisposable
|
public sealed partial class MainViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IIsoController _controller;
|
private readonly IIsoController _controller;
|
||||||
private readonly Dispatcher _dispatcher;
|
private readonly Dispatcher _dispatcher;
|
||||||
|
|
@ -25,15 +31,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
||||||
private string _statusText = "Starting…";
|
private string _statusText = "Starting…";
|
||||||
|
|
||||||
// Auto-apply-last-preset bookkeeping. Set on InitializeAsync from disk;
|
// _pendingPresetName / Deadline / Applied + the auto-apply path
|
||||||
// cleared once we successfully apply (so we don't re-apply when the
|
// moved to MainViewModel.PresetCommands.cs.
|
||||||
// participant list later mutates). The grace deadline gives Teams enough
|
|
||||||
// time to publish all initial sources after engine start before we attempt
|
|
||||||
// the apply — applying before everyone's visible would partially-restore
|
|
||||||
// the routing and silently drop assignments for late-appearing participants.
|
|
||||||
private string? _pendingPresetName;
|
|
||||||
private DateTimeOffset _pendingPresetDeadline;
|
|
||||||
private bool _pendingPresetApplied;
|
|
||||||
|
|
||||||
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
|
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
|
||||||
|
|
||||||
|
|
@ -431,25 +430,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
JoinMeetingCommand = new RelayCommand(() =>
|
JoinMeetingCommand = new RelayCommand(JoinPastedMeeting);
|
||||||
{
|
|
||||||
// Trim + handle the operator pasting whitespace around the URL.
|
|
||||||
var url = (_joinMeetingUrl ?? string.Empty).Trim();
|
|
||||||
if (string.IsNullOrEmpty(url)) return;
|
|
||||||
if (TeamsLauncher.TryJoinMeeting(url, out var error))
|
|
||||||
{
|
|
||||||
Toast.Show("Joining Teams meeting…");
|
|
||||||
JoinMeetingUrl = string.Empty;
|
|
||||||
// If the operator has auto-hide on, kick off the hide watcher
|
|
||||||
// so the Teams meeting window goes away as soon as it renders.
|
|
||||||
if (Settings.AutoHideTeamsWindows)
|
|
||||||
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Toast.Warn($"Could not join: {error}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ToggleMuteCommand = MakeTeamsCommand(
|
ToggleMuteCommand = MakeTeamsCommand(
|
||||||
label: "Mute",
|
label: "Mute",
|
||||||
|
|
@ -469,202 +450,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
successMessage: "Opened share tray");
|
successMessage: "Opened share tray");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Body methods extracted to themed partial files:
|
||||||
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand that
|
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
|
||||||
/// translates the result to a user-visible toast. Centralizes the toast wording
|
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
|
||||||
/// so the four control commands stay consistent.
|
// ExtractMeetingTitle, PollTeamsMeetingState
|
||||||
/// </summary>
|
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
|
||||||
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
|
// LoadPendingPresetFromPreferences,
|
||||||
{
|
// TryAutoApplyPendingPreset
|
||||||
return new RelayCommand(() =>
|
|
||||||
{
|
|
||||||
switch (invoke())
|
|
||||||
{
|
|
||||||
case TeamsControlBridge.InvokeResult.Invoked:
|
|
||||||
Toast.Show(successMessage);
|
|
||||||
break;
|
|
||||||
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
|
|
||||||
Toast.Warn("Teams isn't running.");
|
|
||||||
break;
|
|
||||||
case TeamsControlBridge.InvokeResult.ControlNotFound:
|
|
||||||
Toast.Warn($"{label} control not found — are you in a call?");
|
|
||||||
break;
|
|
||||||
case TeamsControlBridge.InvokeResult.InvokeFailed:
|
|
||||||
Toast.Warn($"{label} button found but disabled.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Issues DisableIsoAsync for every participant whose ISO is currently enabled.
|
|
||||||
/// Each disable is awaited sequentially so we don't try to tear down N pipelines
|
|
||||||
/// in parallel and trip channel-completion races; for ~10 participants this is
|
|
||||||
/// still sub-second total. Failures are swallowed (best-effort emergency stop).
|
|
||||||
/// </summary>
|
|
||||||
// RollRecordingAsync removed — recording feature axed.
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enable ISOs for every online + non-enabled participant in parallel-ish
|
|
||||||
/// (sequential await, but each individual EnableIsoAsync is fast). Tolerates
|
|
||||||
/// per-participant failures so one bad source doesn't abort the rest.
|
|
||||||
/// </summary>
|
|
||||||
private async Task EnableAllOnlineAsync()
|
|
||||||
{
|
|
||||||
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
|
|
||||||
var enabled = 0;
|
|
||||||
foreach (var p in candidates)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
|
|
||||||
? Services.OutputNameTemplate.Render(
|
|
||||||
Services.OutputNameTemplate.Get(),
|
|
||||||
p.Id,
|
|
||||||
p.DisplayName)
|
|
||||||
: p.CustomName;
|
|
||||||
// 3-arg overload (no recordOverride) — recording surface axed,
|
|
||||||
// so the engine's per-pipeline recorder sink stays unattached.
|
|
||||||
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
|
|
||||||
p.IsEnabled = true;
|
|
||||||
enabled++;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Per-participant best-effort — one bad source shouldn't abort the bulk operation.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Toast.Show(enabled == 0
|
|
||||||
? "No participants to enable"
|
|
||||||
: $"Enabled {enabled} ISO(s)");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StopAllIsosAsync()
|
|
||||||
{
|
|
||||||
// Snapshot first so the collection doesn't mutate while we iterate.
|
|
||||||
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
|
||||||
if (enabled.Length == 0)
|
|
||||||
{
|
|
||||||
Toast.Show("No ISOs to stop");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Confirm before tearing down — this button is an "emergency stop" but
|
|
||||||
// mis-clicks during a show are easy. The dialog cost is negligible
|
|
||||||
// (one Enter press) and the regret cost is huge (yanking 5 ISOs mid-
|
|
||||||
// broadcast). Default selection is No so accidental hits cancel.
|
|
||||||
var confirm = System.Windows.MessageBox.Show(
|
|
||||||
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
|
|
||||||
"TeamsISO — Stop all ISOs",
|
|
||||||
System.Windows.MessageBoxButton.YesNo,
|
|
||||||
System.Windows.MessageBoxImage.Warning,
|
|
||||||
System.Windows.MessageBoxResult.No);
|
|
||||||
if (confirm != System.Windows.MessageBoxResult.Yes) return;
|
|
||||||
|
|
||||||
foreach (var p in enabled)
|
|
||||||
{
|
|
||||||
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
|
||||||
catch { /* defensive */ }
|
|
||||||
p.IsEnabled = false;
|
|
||||||
}
|
|
||||||
Toast.Show($"Stopped {enabled.Length} ISO(s)");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Save a PNG of every currently-enabled participant's latest processed
|
|
||||||
/// frame to a timestamped subdirectory under
|
|
||||||
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
|
|
||||||
/// so back-to-back clicks don't comingle. Useful for end-of-meeting
|
|
||||||
/// archives, recapping who showed up, etc.
|
|
||||||
/// </summary>
|
|
||||||
private void SnapshotAll()
|
|
||||||
{
|
|
||||||
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
|
||||||
if (enabled.Length == 0)
|
|
||||||
{
|
|
||||||
Toast.Warn("No enabled participants to snapshot");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rootDir = System.IO.Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
|
|
||||||
"TeamsISO",
|
|
||||||
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
System.IO.Directory.CreateDirectory(rootDir);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var saved = 0;
|
|
||||||
var failed = 0;
|
|
||||||
foreach (var p in enabled)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var frame = _controller.GetLatestProcessedFrame(p.Id);
|
|
||||||
if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }
|
|
||||||
|
|
||||||
var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
|
|
||||||
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
|
|
||||||
var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");
|
|
||||||
|
|
||||||
var stride = frame.Width * 4;
|
|
||||||
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
|
|
||||||
frame.Width, frame.Height, 96, 96,
|
|
||||||
System.Windows.Media.PixelFormats.Bgra32, null);
|
|
||||||
bmp.WritePixels(
|
|
||||||
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
|
|
||||||
frame.Pixels.ToArray(), stride, 0);
|
|
||||||
|
|
||||||
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
|
|
||||||
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
|
||||||
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
|
|
||||||
encoder.Save(fs);
|
|
||||||
saved++;
|
|
||||||
}
|
|
||||||
catch { failed++; }
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.Show(failed > 0
|
|
||||||
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
|
|
||||||
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatBytes removed — its only caller was the recording free-space footer
|
|
||||||
// label, which went away with the rest of the recording surface.
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pull the meaningful "meeting title" out of Teams' raw window title.
|
|
||||||
/// Teams uses formats like:
|
|
||||||
/// "Weekly Standup | Microsoft Teams"
|
|
||||||
/// "Meeting with Alice | Microsoft Teams"
|
|
||||||
/// "Microsoft Teams" (no meeting, just the app)
|
|
||||||
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
|
|
||||||
/// short and readable. Truncate beyond 50 chars so a long meeting
|
|
||||||
/// subject doesn't push the rest of the IN-CALL bar off screen.
|
|
||||||
/// </summary>
|
|
||||||
internal static string ExtractMeetingTitle(string windowTitle)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
|
|
||||||
var t = windowTitle.Trim();
|
|
||||||
// Common separator patterns Teams uses across locales.
|
|
||||||
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
|
|
||||||
{
|
|
||||||
var idx = t.IndexOf(sep, StringComparison.Ordinal);
|
|
||||||
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
|
|
||||||
}
|
|
||||||
// If after stripping we're left with just "Microsoft Teams" the
|
|
||||||
// window has no meeting context — return empty so the pill stays
|
|
||||||
// at "IN CALL" without a stale title.
|
|
||||||
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
|
||||||
if (t.Length > 50) t = t.Substring(0, 47) + "…";
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnStatsTick(object? sender, EventArgs e)
|
private void OnStatsTick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
|
@ -777,52 +569,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
|
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teams meeting state — UIA traversal at 1Hz. We probe by looking
|
// Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
|
||||||
// for the Leave button in Teams' automation tree (present iff in a
|
// UIA call doesn't stall the UI tick. Implementation in
|
||||||
// call) and surface the result as a status pill in the IN-CALL bar.
|
// MainViewModel.TeamsCommands.cs.
|
||||||
// Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
|
PollTeamsMeetingState();
|
||||||
// the property update is dispatched back here on next tick.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var teamsRunning = TeamsLauncher.IsRunning();
|
|
||||||
if (!teamsRunning)
|
|
||||||
{
|
|
||||||
TeamsMeetingState = string.Empty;
|
|
||||||
IsTeamsInCall = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fire the UIA probe off-thread — it walks the full descendant
|
|
||||||
// tree of every Teams window and can take 50-200ms in a busy
|
|
||||||
// call. We can tolerate one-tick latency on the displayed
|
|
||||||
// state much more easily than a UI hiccup.
|
|
||||||
_ = Task.Run(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Single UIA traversal returns all three signals — in-call,
|
|
||||||
// muted, camera-off — so we don't pay for three walks of
|
|
||||||
// the same descendant tree at 1Hz.
|
|
||||||
var snap = TeamsControlBridge.DetectCallState();
|
|
||||||
var inCall = snap.IsInCall;
|
|
||||||
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
|
|
||||||
_dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
IsTeamsInCall = inCall;
|
|
||||||
TeamsMeetingState = inCall
|
|
||||||
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
|
|
||||||
: "READY";
|
|
||||||
// Mute / camera state — only meaningful in-call.
|
|
||||||
IsLocalMuted = inCall && (snap.IsMuted ?? false);
|
|
||||||
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
|
|
||||||
// Auto-record-on-call hook removed alongside recording feature.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch { /* UIA flakiness shouldn't crash the stats tick */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* defensive — probe failures must never break the tick */ }
|
|
||||||
|
|
||||||
// Control-surface state — peek at App's owned services.
|
// Control-surface state — peek at App's owned services.
|
||||||
var app = System.Windows.Application.Current as App;
|
var app = System.Windows.Application.Current as App;
|
||||||
|
|
@ -853,36 +603,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
await _controller.StartAsync(cancellationToken);
|
await _controller.StartAsync(cancellationToken);
|
||||||
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
||||||
|
|
||||||
// Auto-apply last preset bookkeeping. We don't apply here — participants
|
// Auto-apply last preset bookkeeping. We don't apply here —
|
||||||
// haven't been discovered yet — instead we record the intent and let
|
// participants haven't been discovered yet — instead we record
|
||||||
// OnParticipantsChanged trigger the apply once the meeting has populated.
|
// the intent and let OnParticipantsChanged trigger the apply
|
||||||
try
|
// once the meeting has populated. Implementation in
|
||||||
{
|
// MainViewModel.PresetCommands.cs.
|
||||||
var pref = Services.OperatorPresetStore.GetStartupPreference();
|
LoadPendingPresetFromPreferences();
|
||||||
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
|
|
||||||
{
|
|
||||||
_pendingPresetName = pref.LastAppliedName;
|
|
||||||
// 30s grace window is generous: Teams typically advertises all
|
|
||||||
// existing participants within 5–10s of NDI discovery starting.
|
|
||||||
// After this deadline we apply with whoever is visible.
|
|
||||||
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* preset read failures shouldn't block engine startup */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
|
|
||||||
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
|
|
||||||
/// that the user-toggled auto-apply path uses, so a single trigger flow
|
|
||||||
/// covers both. Wins over the persisted preference (operator's CLI intent
|
|
||||||
/// is more recent than what's on disk).
|
|
||||||
/// </summary>
|
|
||||||
public void RequestApplyPresetOnStartup(string presetName)
|
|
||||||
{
|
|
||||||
_pendingPresetName = presetName;
|
|
||||||
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
|
||||||
_pendingPresetApplied = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
|
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
|
||||||
|
|
@ -960,50 +686,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to apply <see cref="_pendingPresetName"/> if either every preset
|
|
||||||
/// assignment matches a live participant, or the grace deadline has passed.
|
|
||||||
/// Idempotent — repeat calls without state change are no-ops; once we fire we
|
|
||||||
/// flag <c>_pendingPresetApplied</c> so subsequent participant churn doesn't
|
|
||||||
/// trigger a second apply. Failures (missing preset on disk, preset that no
|
|
||||||
/// longer matches anyone) are swallowed: the operator can always re-apply
|
|
||||||
/// manually via the dialog. Delegates to <see cref="PresetApplier.ApplyAsync"/>
|
|
||||||
/// for the actual reconciliation so the dialog, REST surface, and this auto-
|
|
||||||
/// apply path all share a single implementation.
|
|
||||||
/// </summary>
|
|
||||||
private void TryAutoApplyPendingPreset()
|
|
||||||
{
|
|
||||||
Services.OperatorPresetStore.Preset? preset;
|
|
||||||
try { preset = Services.OperatorPresetStore.Find(_pendingPresetName!); }
|
|
||||||
catch { preset = null; }
|
|
||||||
if (preset is null)
|
|
||||||
{
|
|
||||||
_pendingPresetApplied = true; // give up; nothing on disk to apply
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var liveNames = new HashSet<string>(
|
|
||||||
Participants.Select(p => p.DisplayName),
|
|
||||||
StringComparer.OrdinalIgnoreCase);
|
|
||||||
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
|
|
||||||
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
|
|
||||||
return; // wait for the rest of the meeting to populate
|
|
||||||
|
|
||||||
_pendingPresetApplied = true;
|
|
||||||
var captured = preset;
|
|
||||||
// Snapshot the participants list since we're about to await on a worker
|
|
||||||
// thread; the live ObservableCollection isn't safe to enumerate from
|
|
||||||
// outside the dispatcher.
|
|
||||||
var snapshot = Participants.ToList();
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var result = await PresetApplier.ApplyAsync(
|
|
||||||
captured, snapshot, _controller, _dispatcher);
|
|
||||||
await _dispatcher.InvokeAsync(() =>
|
|
||||||
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsLocalSelf(Participant p) =>
|
private static bool IsLocalSelf(Participant p) =>
|
||||||
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
|
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue