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>
130 lines
5.3 KiB
C#
130 lines
5.3 KiB
C#
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 */ }
|
||
}
|
||
}
|