IN-CALL bar surfaces Teams meeting state — 'READY' / 'IN CALL'
Some checks failed
CI / build-and-test (push) Failing after 27s
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:
parent
8e08d7dc6a
commit
a9a10e01a4
3 changed files with 144 additions and 1 deletions
|
|
@ -473,7 +473,41 @@
|
|||
<TextBlock Text="IN-CALL"
|
||||
Style="{StaticResource Wd.Text.Caption}"
|
||||
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}"
|
||||
Command="{Binding ToggleMuteCommand}"
|
||||
Padding="14,6"
|
||||
|
|
|
|||
|
|
@ -171,6 +171,51 @@ public static class TeamsControlBridge
|
|||
public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates);
|
||||
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)
|
||||
{
|
||||
var roots = GetTeamsAutomationRoots();
|
||||
|
|
|
|||
|
|
@ -206,6 +206,34 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
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>
|
||||
/// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
|
||||
/// 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";
|
||||
}
|
||||
|
||||
// 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.
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var rest = app?.ControlSurface?.IsRunning ?? false;
|
||||
|
|
|
|||
Loading…
Reference in a new issue