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).
456 lines
18 KiB
C#
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 ? "" : "";
|
|
}
|
|
|
|
}
|