teamsiso/src/TeamsISO.App.WinUI/Views/MainWindow.xaml.cs
Zac Gaetano 7ac56c2661 feat(winui3): wire Teams orchestration into the in-call bar + rail buttons
In-call control bar now drives the live Teams app via UIAutomation:

  * Mute button → TeamsControlBridge.ToggleMute()
  * Camera button → TeamsControlBridge.ToggleCamera()
  * Share button → TeamsControlBridge.OpenShareTray()
  * Leave button → TeamsControlBridge.LeaveCall()

Each button reports the result through the status bar (Invoked /
Teams-not-running / Control-not-visible / Invoke-failed).

Rail buttons also wired:

  * Launch / surface Teams → TeamsLauncher.IsRunning()/TryLaunch()/ShowWindows()
  * Hide / show Teams windows → TeamsLauncher.HideWindows()/ShowWindows()
    with a _teamsHidden flag tracking the toggle state

The Marker button was already command-bound to MainViewModel.DropRecording
MarkerCommand (which fans out to IIsoController.AddRecordingMarker), so
the only thing that wasn't covered before is the Teams-side stuff.

Implementation notes:

  * Services/TeamsControlBridge.cs and Services/TeamsLauncher.cs are
    copied verbatim from src/TeamsISO.App/Services/ with only the
    namespace adjusted (TeamsISO.App.Services → TeamsISO.App.WinUI.
    Services). Neither file has WPF-specific dependencies — they use
    System.Windows.Automation (UIAutomationClient) which works
    identically across WPF and WinUI 3 builds. Duplication is
    acceptable migration debt; the long-term plan is to lift these
    into a shared TeamsISO.App.Shared library once both hosts
    stabilize.
  * DescribeBridgeResult maps the InvokeResult enum to operator-tone
    status text so a failing mute reads "Mute failed — control not
    visible (not in a call?)" instead of an opaque "ControlNotFound".

The in-call bar now does what the WPF host's in-call bar does, minus
the MUTED / CAM OFF state pills (those would need a 1Hz UIA poll of
the Teams call state — wire-up to come).
2026-05-13 21:31:04 -04:00

456 lines
18 KiB
C#

using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using TeamsISO.App.WinUI.Services;
using TeamsISO.App.WinUI.ViewModels;
using Windows.Graphics;
using Windows.UI;
namespace TeamsISO.App.WinUI.Views;
public sealed partial class MainWindow : Window
{
private MainViewModel? _viewModel;
public MainWindow()
{
InitializeComponent();
Title = "TeamsISO";
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);
AppWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent;
AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
AppWindow.TitleBar.ButtonHoverForegroundColor = Colors.White;
AppWindow.Resize(new SizeInt32(1280, 780));
ThemeManager.Current.Themed += (_, theme) => ApplyResolvedTheme(theme);
ApplyResolvedTheme(ThemeManager.Current.ResolveTheme());
}
/// <summary>
/// Hook the engine view-model in. Replaces the placeholder StackPanel
/// inside ParticipantsHost with a live ListView. Rather than fight WinUI
/// 3's DataTemplate compilation, we subscribe to the Participants
/// collection and rebuild a simple StackPanel of row controls on
/// every change. Less efficient than a virtualized ListView for huge
/// lists, fine for the operator's ~10 max participants.
/// </summary>
public void AttachViewModel(MainViewModel viewModel)
{
_viewModel = viewModel;
// Section header + in-call buttons → view-model commands.
// The buttons exist in MainWindow.xaml with the matching x:Names.
RefreshButton.Command = viewModel.RefreshDiscoveryCommand;
EnableAllButton.Command = viewModel.EnableAllOnlineCommand;
StopAllButton.Command = viewModel.StopAllIsosCommand;
MarkerButton.Command = viewModel.DropRecordingMarkerCommand;
// Status bar + participant count text refresh on VM property changes.
ParticipantCountText.Text = viewModel.ParticipantCountText;
StatusBarText.Text = viewModel.StatusText;
viewModel.PropertyChanged += (_, e) =>
{
DispatcherQueue.TryEnqueue(() =>
{
switch (e.PropertyName)
{
case nameof(MainViewModel.ParticipantCountText):
ParticipantCountText.Text = viewModel.ParticipantCountText;
break;
case nameof(MainViewModel.StatusText):
StatusBarText.Text = viewModel.StatusText;
break;
}
});
};
ParticipantsHost.Children.Clear();
var stack = new Microsoft.UI.Xaml.Controls.StackPanel
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Top,
};
var scroll = new Microsoft.UI.Xaml.Controls.ScrollViewer
{
VerticalScrollBarVisibility = Microsoft.UI.Xaml.Controls.ScrollBarVisibility.Auto,
Content = stack,
};
ParticipantsHost.Children.Add(scroll);
void Rebuild()
{
stack.Children.Clear();
foreach (var p in viewModel.Participants)
{
stack.Children.Add(BuildSimpleRow(p));
}
}
viewModel.Participants.CollectionChanged += (_, _) =>
{
DispatcherQueue.TryEnqueue(Rebuild);
};
Rebuild();
}
/// <summary>
/// Minimal participant row — name + ISO state + toggle button. Drops
/// the brushed avatar / theme-resource lookups that may have been
/// triggering the crash. The full visual row template comes back
/// after we've verified the binding path works.
/// </summary>
private static Microsoft.UI.Xaml.Controls.Grid BuildSimpleRow(ParticipantViewModel p)
{
var grid = new Microsoft.UI.Xaml.Controls.Grid
{
Height = 56,
Padding = new Thickness(20, 0, 20, 0),
};
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1, Microsoft.UI.Xaml.GridUnitType.Star) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(120) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto });
var nameStack = new Microsoft.UI.Xaml.Controls.StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
Spacing = 2,
};
var nameText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.DisplayName,
FontSize = 14,
FontWeight = Microsoft.UI.Text.FontWeights.Medium,
};
var codecText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.SourceCodec,
FontSize = 11,
Opacity = 0.6,
};
nameStack.Children.Add(nameText);
nameStack.Children.Add(codecText);
Microsoft.UI.Xaml.Controls.Grid.SetColumn(nameStack, 0);
grid.Children.Add(nameStack);
var outputText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.OutputName,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(outputText, 1);
grid.Children.Add(outputText);
var pillText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.IsoStateLabel,
FontSize = 11,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Center,
};
var pill = new Microsoft.UI.Xaml.Controls.Button
{
Command = p.ToggleIsoCommand,
MinWidth = 80,
Padding = new Thickness(14, 6, 14, 6),
CornerRadius = new CornerRadius(999),
VerticalAlignment = VerticalAlignment.Center,
Content = pillText,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(pill, 2);
grid.Children.Add(pill);
p.PropertyChanged += (_, e) =>
{
grid.DispatcherQueue.TryEnqueue(() =>
{
switch (e.PropertyName)
{
case nameof(ParticipantViewModel.DisplayName):
nameText.Text = p.DisplayName;
break;
case nameof(ParticipantViewModel.SourceCodec):
codecText.Text = p.SourceCodec;
break;
case nameof(ParticipantViewModel.OutputName):
outputText.Text = p.OutputName;
break;
case nameof(ParticipantViewModel.IsoStateLabel):
case nameof(ParticipantViewModel.IsEnabled):
pillText.Text = p.IsoStateLabel;
break;
}
});
};
return grid;
}
/// <summary>Full rich row template — replaces BuildSimpleRow once we've verified the simple version doesn't crash.</summary>
private static Microsoft.UI.Xaml.Controls.Grid BuildParticipantRow(ParticipantViewModel p)
{
var grid = new Microsoft.UI.Xaml.Controls.Grid
{
Height = 64,
Padding = new Thickness(14, 0, 12, 0),
BorderBrush = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BorderSubtle"],
BorderThickness = new Thickness(0, 0, 0, 1),
};
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(44) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(2, Microsoft.UI.Xaml.GridUnitType.Star) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1.4, Microsoft.UI.Xaml.GridUnitType.Star) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1.6, Microsoft.UI.Xaml.GridUnitType.Star) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto });
// Avatar
var avatar = new Microsoft.UI.Xaml.Controls.Border
{
Width = 36, Height = 36,
CornerRadius = new CornerRadius(18),
Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCyanMuted"],
VerticalAlignment = VerticalAlignment.Center,
};
var initialsText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.Initials,
FontSize = 13,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCyanText"],
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
avatar.Child = initialsText;
Microsoft.UI.Xaml.Controls.Grid.SetColumn(avatar, 0);
grid.Children.Add(avatar);
// Name + codec
var nameStack = new Microsoft.UI.Xaml.Controls.StackPanel
{
Margin = new Thickness(12, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
Spacing = 2,
};
var nameText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.DisplayName,
FontSize = 13,
FontWeight = Microsoft.UI.Text.FontWeights.Medium,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
};
var codecText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.SourceCodec,
FontSize = 11,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgSecondary"],
};
nameStack.Children.Add(nameText);
nameStack.Children.Add(codecText);
Microsoft.UI.Xaml.Controls.Grid.SetColumn(nameStack, 1);
grid.Children.Add(nameStack);
// Audio meter
var meter = new Microsoft.UI.Xaml.Controls.ProgressBar
{
Maximum = 1.0,
Value = p.DisplayedAudioLevel,
Height = 4,
Margin = new Thickness(12, 0, 12, 0),
VerticalAlignment = VerticalAlignment.Center,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(meter, 2);
grid.Children.Add(meter);
// Output name
var outputText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.OutputName,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
FontSize = 12,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
VerticalAlignment = VerticalAlignment.Center,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(outputText, 3);
grid.Children.Add(outputText);
// ISO toggle pill
var pillText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.IsoStateLabel,
FontSize = 11,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
HorizontalAlignment = HorizontalAlignment.Center,
};
var pill = new Microsoft.UI.Xaml.Controls.Button
{
Command = p.ToggleIsoCommand,
MinWidth = 80,
Padding = new Thickness(14, 6, 14, 6),
Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BgSurface"],
BorderBrush = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BorderStrong"],
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
VerticalAlignment = VerticalAlignment.Center,
Content = pillText,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(pill, 4);
grid.Children.Add(pill);
// Per-row property-change subscription — refresh text as the
// engine pushes updates.
p.PropertyChanged += (_, e) =>
{
grid.DispatcherQueue.TryEnqueue(() =>
{
switch (e.PropertyName)
{
case nameof(ParticipantViewModel.DisplayName):
nameText.Text = p.DisplayName;
initialsText.Text = p.Initials;
break;
case nameof(ParticipantViewModel.SourceCodec):
codecText.Text = p.SourceCodec;
break;
case nameof(ParticipantViewModel.DisplayedAudioLevel):
meter.Value = p.DisplayedAudioLevel;
break;
case nameof(ParticipantViewModel.OutputName):
outputText.Text = p.OutputName;
break;
case nameof(ParticipantViewModel.IsoStateLabel):
case nameof(ParticipantViewModel.IsEnabled):
pillText.Text = p.IsoStateLabel;
break;
}
});
};
return grid;
}
private void OnThemeToggleClick(object sender, RoutedEventArgs e)
{
ThemeManager.Current.Toggle();
}
private bool _drawerOpen;
private void OnSettingsClick(object sender, RoutedEventArgs e)
{
_drawerOpen = !_drawerOpen;
SettingsDrawerHost.Visibility = _drawerOpen
? Visibility.Visible
: Visibility.Collapsed;
if (_drawerOpen)
{
SettingsDrawerHost.CloseRequested -= OnDrawerCloseRequested;
SettingsDrawerHost.CloseRequested += OnDrawerCloseRequested;
}
}
private void OnDrawerCloseRequested(object? sender, System.EventArgs e)
{
_drawerOpen = false;
SettingsDrawerHost.Visibility = Visibility.Collapsed;
}
/// <summary>
/// Teams orchestration — mute. Drives the Teams app's in-call mute button
/// via UIAutomation (TeamsControlBridge does the localized-name search).
/// Surface failures via the status bar so the operator gets feedback
/// without a popup.
/// </summary>
private void OnMuteClick(object sender, RoutedEventArgs e)
{
var result = Services.TeamsControlBridge.ToggleMute();
StatusBarText.Text = DescribeBridgeResult("Mute", result);
}
private void OnCameraClick(object sender, RoutedEventArgs e)
{
var result = Services.TeamsControlBridge.ToggleCamera();
StatusBarText.Text = DescribeBridgeResult("Camera", result);
}
private void OnShareClick(object sender, RoutedEventArgs e)
{
var result = Services.TeamsControlBridge.OpenShareTray();
StatusBarText.Text = DescribeBridgeResult("Share", result);
}
private void OnLeaveClick(object sender, RoutedEventArgs e)
{
var result = Services.TeamsControlBridge.LeaveCall();
StatusBarText.Text = DescribeBridgeResult("Leave", result);
}
private static string DescribeBridgeResult(string action, Services.TeamsControlBridge.InvokeResult r) =>
r switch
{
Services.TeamsControlBridge.InvokeResult.Invoked => $"{action} invoked",
Services.TeamsControlBridge.InvokeResult.TeamsNotRunning => $"{action} failed — Teams isn't running",
Services.TeamsControlBridge.InvokeResult.ControlNotFound => $"{action} failed — control not visible (not in a call?)",
Services.TeamsControlBridge.InvokeResult.InvokeFailed => $"{action} failed — Teams refused the invoke",
_ => $"{action} failed",
};
/// <summary>Launch Teams if not running, else show its windows.</summary>
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
{
if (Services.TeamsLauncher.IsRunning())
{
var shown = Services.TeamsLauncher.ShowWindows();
StatusBarText.Text = $"Showed {shown} Teams window{(shown == 1 ? "" : "s")}";
}
else
{
if (Services.TeamsLauncher.TryLaunch(out var err))
{
StatusBarText.Text = "Teams launched";
}
else
{
StatusBarText.Text = $"Teams launch failed: {err}";
}
}
}
/// <summary>Toggle Teams window visibility — invisible/visible flip.</summary>
private bool _teamsHidden;
private void OnToggleTeamsWindowsClick(object sender, RoutedEventArgs e)
{
if (_teamsHidden)
{
var n = Services.TeamsLauncher.ShowWindows();
_teamsHidden = false;
StatusBarText.Text = $"Showed {n} Teams window{(n == 1 ? "" : "s")}";
}
else
{
var n = Services.TeamsLauncher.HideWindows();
_teamsHidden = true;
StatusBarText.Text = $"Hid {n} Teams window{(n == 1 ? "" : "s")}";
}
}
private void ApplyResolvedTheme(ElementTheme theme)
{
if (Content is FrameworkElement root)
{
root.RequestedTheme = theme;
}
AppWindow.TitleBar.ButtonForegroundColor = ThemeManager.TitleBarForegroundFor(theme);
AppWindow.TitleBar.ButtonHoverBackgroundColor = ThemeManager.TitleBarHoverBgFor(theme);
AppWindow.TitleBar.ButtonPressedBackgroundColor = ThemeManager.TitleBarHoverBgFor(theme);
ThemeToggleIcon.Glyph = theme == ElementTheme.Light ? "" : "";
}
}