IN-CALL bar surfaces Teams meeting state — 'READY' / 'IN CALL'
Some checks failed
CI / build-and-test (push) Failing after 27s

Operators using auto-hide Teams couldn't tell whether they were in a meeting without restoring the Teams window. New status pill in the IN-CALL bar header shows:

  • empty when Teams isn't running

  • 'READY' (gray dot) when Teams is running but not in a call

  • 'IN CALL' (cyan dot) when Teams is in an active meeting

Detection: TeamsControlBridge.IsInCall() walks Teams' UIA tree looking for the Leave / Hang-up button. Present iff in a call — works across Teams versions because Teams only exposes the Leave control while a call is active. Same candidate-name list the LeaveCall command uses, with localized strings for EN/DE/ES/FR/PT/JA already in place.

Polled at the existing 1Hz stats tick. UIA traversal can take 50-200ms in a busy call, so the probe runs off-thread; the property update is dispatched back via _dispatcher.InvokeAsync. Failure paths swallow exceptions — a flaky UIA call must never crash the stats timer.

159/159 tests passing, 0 warnings, 0 errors.
This commit is contained in:
Zac Gaetano 2026-05-10 20:42:57 -04:00
parent 8e08d7dc6a
commit a9a10e01a4
3 changed files with 144 additions and 1 deletions

View file

@ -473,7 +473,41 @@
<TextBlock Text="IN-CALL" <TextBlock Text="IN-CALL"
Style="{StaticResource Wd.Text.Caption}" Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,16,0"/> Margin="0,0,12,0"/>
<!-- Teams meeting state pill — populated from a UIA probe
at the existing 1Hz stats tick. Visible only when Teams
is running. Cyan dot when in a call, gray when Teams is
open but idle. So an operator with Teams auto-hidden can
see at a glance whether they're in a meeting. -->
<Border Style="{StaticResource Wd.Pill}"
VerticalAlignment="Center"
Margin="0,0,16,0"
Padding="10,3"
Visibility="{Binding HasTeamsState, Converter={StaticResource BoolToVis}}"
ToolTip="Microsoft Teams meeting state (probed via UIAutomation at 1Hz)">
<StackPanel Orientation="Horizontal">
<Border Width="6" Height="6"
CornerRadius="3"
Margin="0,0,6,0"
VerticalAlignment="Center">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource Wd.Text.Tertiary}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsTeamsInCall}" Value="True">
<Setter Property="Background" Value="{DynamicResource Wd.Accent.Cyan}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<TextBlock Text="{Binding TeamsMeetingState}"
Style="{StaticResource Wd.Text.Caption}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleMuteCommand}" Command="{Binding ToggleMuteCommand}"
Padding="14,6" Padding="14,6"

View file

@ -171,6 +171,51 @@ public static class TeamsControlBridge
public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates); public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates);
public static InvokeResult OpenBackgroundEffects() => InvokeFirstMatch(BackgroundBlurCandidates); public static InvokeResult OpenBackgroundEffects() => InvokeFirstMatch(BackgroundBlurCandidates);
/// <summary>
/// True if Teams is currently in an active call. The Leave / Hang-up
/// button only exists in the automation tree when a call is in progress,
/// so its presence is a reliable in-call signal across Teams versions.
/// Returns false if Teams isn't running, isn't in a call, or the call
/// UI is in a state we don't recognize.
///
/// This is the "tell me what Teams is doing without me having to look
/// at it" probe — operators using auto-hide Teams want a status pill
/// that says "In call · ready" without having to restore the Teams
/// window. Safe to call from any thread (UIA traversal is thread-safe);
/// not free (walks the descendant tree) so callers should poll at most
/// a few times per second.
/// </summary>
public static bool IsInCall()
{
var roots = GetTeamsAutomationRoots();
if (roots.Count == 0) return false;
foreach (var root in roots)
{
AutomationElementCollection allButtons;
try
{
allButtons = root.FindAll(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
}
catch
{
// Window died mid-traversal; try the next root.
continue;
}
foreach (AutomationElement btn in allButtons)
{
var name = SafeGetName(btn);
if (string.IsNullOrEmpty(name)) continue;
if (LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
return true;
}
}
return false;
}
private static InvokeResult InvokeFirstMatch(IReadOnlyList<string> candidateNames) private static InvokeResult InvokeFirstMatch(IReadOnlyList<string> candidateNames)
{ {
var roots = GetTeamsAutomationRoots(); var roots = GetTeamsAutomationRoots();

View file

@ -206,6 +206,34 @@ public sealed class MainViewModel : ObservableObject, IDisposable
} }
private string _controlSurfaceText = string.Empty; private string _controlSurfaceText = string.Empty;
/// <summary>
/// "IN CALL" when Teams is in an active meeting; "READY" when Teams is
/// running but not in a call; empty when Teams isn't running. Surfaced
/// as a status pill in the IN-CALL bar so operators with auto-hide on
/// can see Teams' state without restoring its window.
/// </summary>
public string TeamsMeetingState
{
get => _teamsMeetingState;
private set
{
if (SetField(ref _teamsMeetingState, value))
OnPropertyChanged(nameof(HasTeamsState));
}
}
private string _teamsMeetingState = string.Empty;
/// <summary>True when Teams is currently in a call (Leave button present in UIA tree).</summary>
public bool IsTeamsInCall
{
get => _isTeamsInCall;
private set => SetField(ref _isTeamsInCall, value);
}
private bool _isTeamsInCall;
/// <summary>True when <see cref="TeamsMeetingState"/> is non-empty. Used to gate visibility of the IN-CALL bar status pill via the existing BoolToVis converter.</summary>
public bool HasTeamsState => !string.IsNullOrEmpty(_teamsMeetingState);
/// <summary> /// <summary>
/// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty /// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
/// when nothing's running. Useful for operators tracking show length. /// when nothing's running. Useful for operators tracking show length.
@ -540,6 +568,42 @@ public sealed class MainViewModel : ObservableObject, IDisposable
StatusText = $"{enabledCount}/{totalParticipants} ISOs live"; StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
} }
// Teams meeting state — UIA traversal at 1Hz. We probe by looking
// for the Leave button in Teams' automation tree (present iff in a
// call) and surface the result as a status pill in the IN-CALL bar.
// Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
// the property update is dispatched back here on next tick.
try
{
var teamsRunning = TeamsLauncher.IsRunning();
if (!teamsRunning)
{
TeamsMeetingState = string.Empty;
IsTeamsInCall = false;
}
else
{
// Fire the UIA probe off-thread — it walks the full descendant
// tree of every Teams window and can take 50-200ms in a busy
// call. We can tolerate one-tick latency on the displayed
// state much more easily than a UI hiccup.
_ = Task.Run(() =>
{
try
{
var inCall = TeamsControlBridge.IsInCall();
_dispatcher.InvokeAsync(() =>
{
IsTeamsInCall = inCall;
TeamsMeetingState = inCall ? "IN CALL" : "READY";
});
}
catch { /* UIA flakiness shouldn't crash the stats tick */ }
});
}
}
catch { /* defensive — probe failures must never break the tick */ }
// Control-surface state — peek at App's owned services. // Control-surface state — peek at App's owned services.
var app = System.Windows.Application.Current as App; var app = System.Windows.Application.Current as App;
var rest = app?.ControlSurface?.IsRunning ?? false; var rest = app?.ControlSurface?.IsRunning ?? false;