diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs b/src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs
new file mode 100644
index 0000000..c6b3ad6
--- /dev/null
+++ b/src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs
@@ -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
+{
+ ///
+ /// 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.
+ ///
+ 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)");
+ }
+
+ ///
+ /// 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.
+ ///
+ 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)");
+ }
+
+ ///
+ /// 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.
+ ///
+ 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)}");
+ }
+}
diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs b/src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs
new file mode 100644
index 0000000..7bdfc6f
--- /dev/null
+++ b/src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs
@@ -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;
+
+ ///
+ /// CLI override for the launch-time auto-apply. Called from App.OnStartup
+ /// after parsing --apply-preset. 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).
+ ///
+ public void RequestApplyPresetOnStartup(string presetName)
+ {
+ _pendingPresetName = presetName;
+ _pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
+ _pendingPresetApplied = false;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 */ }
+ }
+
+ ///
+ /// Attempts to apply _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 _pendingPresetApplied 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 for the actual
+ /// reconciliation so the dialog, REST surface, and this auto-apply path
+ /// all share a single implementation.
+ ///
+ 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(
+ 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)"));
+ });
+ }
+}
diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs b/src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
new file mode 100644
index 0000000..905f88c
--- /dev/null
+++ b/src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
@@ -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
+{
+ ///
+ /// Wraps a invocation in a RelayCommand
+ /// that translates the result to a user-visible toast. Centralizes the
+ /// toast wording so the four control commands stay consistent.
+ ///
+ private RelayCommand MakeTeamsCommand(string label, Func 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;
+ }
+ });
+ }
+
+ ///
+ /// Body of JoinMeetingCommand. Trims the pasted URL, hands it to
+ /// , and runs the auto-hide
+ /// follow-up if the operator has that preference set.
+ ///
+ 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}");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 */ }
+ }
+}
diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.cs b/src/TeamsISO.App/ViewModels/MainViewModel.cs
index af785b9..bf2616d 100644
--- a/src/TeamsISO.App/ViewModels/MainViewModel.cs
+++ b/src/TeamsISO.App/ViewModels/MainViewModel.cs
@@ -14,8 +14,14 @@ namespace TeamsISO.App.ViewModels;
/// Top-level view model for the main window. Owns the live collection of ,
/// the global settings panel, and the alert banner. Subscribes to 's observables
/// and marshals updates onto the UI dispatcher.
+///
+/// Split across partial files by responsibility:
+/// • MainViewModel.cs — fields, properties, constructor (wires commands), OnStatsTick, Dispose
+/// • MainViewModel.TeamsCommands.cs — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
+/// • MainViewModel.PresetCommands.cs — auto-apply-last-preset path
+/// • MainViewModel.BulkCommands.cs — Stop all / Enable all / Snapshot all
///
-public sealed class MainViewModel : ObservableObject, IDisposable
+public sealed partial class MainViewModel : ObservableObject, IDisposable
{
private readonly IIsoController _controller;
private readonly Dispatcher _dispatcher;
@@ -25,15 +31,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private readonly Dictionary _byId = new();
private string _statusText = "Starting…";
- // Auto-apply-last-preset bookkeeping. 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;
+ // _pendingPresetName / Deadline / Applied + the auto-apply path
+ // moved to MainViewModel.PresetCommands.cs.
public ObservableCollection Participants { get; } = new();
@@ -431,25 +430,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
});
- JoinMeetingCommand = new RelayCommand(() =>
- {
- // 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}");
- }
- });
+ JoinMeetingCommand = new RelayCommand(JoinPastedMeeting);
ToggleMuteCommand = MakeTeamsCommand(
label: "Mute",
@@ -469,202 +450,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
successMessage: "Opened share tray");
}
- ///
- /// Wraps a invocation in a RelayCommand that
- /// translates the result to a user-visible toast. Centralizes the toast wording
- /// so the four control commands stay consistent.
- ///
- private RelayCommand MakeTeamsCommand(string label, Func 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;
- }
- });
- }
-
- ///
- /// 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).
- ///
- // RollRecordingAsync removed — recording feature axed.
-
- ///
- /// 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.
- ///
- 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)");
- }
-
- ///
- /// 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.
- ///
- 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.
-
- ///
- /// 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.
- ///
- 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;
- }
+ // Body methods extracted to themed partial files:
+ // MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
+ // MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
+ // ExtractMeetingTitle, PollTeamsMeetingState
+ // MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
+ // LoadPendingPresetFromPreferences,
+ // TryAutoApplyPendingPreset
private void OnStatsTick(object? sender, EventArgs e)
{
@@ -777,52 +569,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
}
- // Teams meeting state — UIA traversal at 1Hz. We probe by looking
- // for the Leave button in Teams' automation tree (present iff in a
- // call) and surface the result as a status pill in the IN-CALL bar.
- // Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
- // 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 */ }
+ // Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
+ // UIA call doesn't stall the UI tick. Implementation in
+ // MainViewModel.TeamsCommands.cs.
+ PollTeamsMeetingState();
// Control-surface state — peek at App's owned services.
var app = System.Windows.Application.Current as App;
@@ -853,36 +603,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
- // Auto-apply last preset bookkeeping. We don't apply here — participants
- // haven't been discovered yet — instead we record the intent and let
- // OnParticipantsChanged trigger the apply once the meeting has populated.
- try
- {
- var pref = Services.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 */ }
- }
-
- ///
- /// CLI override for the launch-time auto-apply. Called from App.OnStartup
- /// after parsing --apply-preset. 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).
- ///
- public void RequestApplyPresetOnStartup(string presetName)
- {
- _pendingPresetName = presetName;
- _pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
- _pendingPresetApplied = false;
+ // Auto-apply last preset bookkeeping. We don't apply here —
+ // participants haven't been discovered yet — instead we record
+ // the intent and let OnParticipantsChanged trigger the apply
+ // once the meeting has populated. Implementation in
+ // MainViewModel.PresetCommands.cs.
+ LoadPendingPresetFromPreferences();
}
private void OnParticipantsChanged(IReadOnlyList incoming)
@@ -960,50 +686,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
}
- ///
- /// Attempts to apply 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 _pendingPresetApplied 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
- /// for the actual reconciliation so the dialog, REST surface, and this auto-
- /// apply path all share a single implementation.
- ///
- 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(
- 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) =>
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);