teamsiso/src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs

131 lines
5.3 KiB
C#
Raw Normal View History

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