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);