teamsiso/src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
Zac Gaetano d02a2c059b 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>
2026-05-15 19:31:49 -04:00

130 lines
5.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 50200ms 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 */ }
}
}