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"/>,
|
||||
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
|
||||
/// 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>
|
||||
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<Guid, ParticipantViewModel> _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<ParticipantViewModel> 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");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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;
|
||||
}
|
||||
// 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 */ }
|
||||
}
|
||||
|
||||
/// <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;
|
||||
// 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<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) =>
|
||||
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue