IN-CALL bar shows MUTED / CAM OFF pills
Some checks failed
CI / build-and-test (push) Failing after 34s

Operators with auto-hide Teams couldn't tell if they were muted or had their camera off — needed to restore Teams just to check. New coral pills in the IN-CALL bar surface the local-user state, populated from a single UIA traversal that also drives the IN-CALL pill (so the cost stays at one walk per stats tick, not three).

Detection: TeamsControlBridge.DetectCallState returns a CallStateSnapshot with IsInCall + IsMuted + IsCameraOff. The Mute and Camera buttons toggle their UIA Name between 'Mute'/'Unmute' and 'Turn camera off'/'Turn camera on' depending on state; check the more-specific candidate (unmute / turn camera on) first to avoid false positives from substring matching.

Localized for EN / DE / ES / FR / PT / JA — same locale list the candidate-name arrays already cover. Pills visible only when both in-call AND the corresponding state is true; once you unmute, the pill vanishes within ~1s (next stats tick).
This commit is contained in:
Zac Gaetano 2026-05-10 21:17:19 -04:00
parent 33f145624e
commit 61dce2eecd
3 changed files with 137 additions and 1 deletions

View file

@ -510,6 +510,39 @@
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Local mute / camera-off indicators. Visible only
when in-call AND the corresponding state is true.
Tells operators using auto-hide "you're muted /
camera-off right now" without restoring Teams. -->
<Border Style="{StaticResource Wd.Pill}"
VerticalAlignment="Center"
Margin="0,0,6,0"
Padding="8,3"
Background="{DynamicResource Wd.Accent.CoralBg}"
Visibility="{Binding IsLocalMuted, Converter={StaticResource BoolToVis}}"
ToolTip="Your microphone is muted in Teams. Click Mute to unmute.">
<TextBlock Text="MUTED"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"/>
</Border>
<Border Style="{StaticResource Wd.Pill}"
VerticalAlignment="Center"
Margin="0,0,12,0"
Padding="8,3"
Background="{DynamicResource Wd.Accent.CoralBg}"
Visibility="{Binding IsLocalCameraOff, Converter={StaticResource BoolToVis}}"
ToolTip="Your camera is off in Teams. Click Camera to turn it on.">
<TextBlock Text="CAM OFF"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"/>
</Border>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleMuteCommand}"
Padding="14,6"

View file

@ -171,6 +171,86 @@ public static class TeamsControlBridge
public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates);
public static InvokeResult OpenBackgroundEffects() => InvokeFirstMatch(BackgroundBlurCandidates);
/// <summary>
/// Snapshot of the current call's local-user state. Read via a single
/// UIA traversal in <see cref="DetectCallState"/>; null sub-fields when
/// the call isn't active or the button isn't in the tree.
/// </summary>
public sealed record CallStateSnapshot(bool IsInCall, bool? IsMuted, bool? IsCameraOff);
/// <summary>
/// One-shot UIA probe of Teams' in-call controls. The Mute and Camera
/// buttons toggle their Name between "Mute"/"Unmute" and "Turn camera
/// on"/"Turn camera off" depending on state, so reading the Name tells
/// us whether the operator is currently muted / camera-off.
///
/// Returns IsInCall=false if Teams isn't running or no Leave button
/// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't
/// found in this build (defensive — Teams sometimes uses different
/// candidate names across locales).
/// </summary>
public static CallStateSnapshot DetectCallState()
{
var roots = GetTeamsAutomationRoots();
if (roots.Count == 0) return new CallStateSnapshot(false, null, null);
var inCall = false;
bool? muted = null;
bool? camOff = null;
foreach (var root in roots)
{
AutomationElementCollection allButtons;
try
{
allButtons = root.FindAll(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
}
catch { continue; }
foreach (AutomationElement btn in allButtons)
{
var name = SafeGetName(btn);
if (string.IsNullOrEmpty(name)) continue;
var lower = name.ToLowerInvariant();
if (!inCall && LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
inCall = true;
// Mute button: name is "Mute" when active-can-mute, "Unmute"
// when currently muted. Detect by checking for "unmute" first
// (more specific) before falling to "mute" (more general).
if (muted is null)
{
if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") ||
lower.Contains("activar audio") || lower.Contains("activer le micro") ||
lower.Contains("ativar áudio") || lower.Contains("ミュート解除"))
muted = true;
else if (lower.Contains("mute") || lower.Contains("stummschalten") ||
lower.Contains("silenciar") || lower.Contains("désactiver le micro") ||
lower.Contains("desativar áudio") || lower.Contains("ミュート"))
muted = false;
}
// Camera button: name is "Turn camera off" when on, "Turn
// camera on" when off.
if (camOff is null)
{
if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") ||
lower.Contains("activar cámara") || lower.Contains("activer la caméra") ||
lower.Contains("ativar câmera"))
camOff = true;
else if (lower.Contains("turn camera off") || lower.Contains("kamera ausschalten") ||
lower.Contains("desactivar cámara") || lower.Contains("désactiver la caméra") ||
lower.Contains("desativar câmera"))
camOff = false;
}
}
}
return new CallStateSnapshot(inCall, muted, camOff);
}
/// <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,

View file

@ -265,6 +265,22 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// <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>True when the local user's mic is muted in the active Teams call.</summary>
public bool IsLocalMuted
{
get => _isLocalMuted;
private set => SetField(ref _isLocalMuted, value);
}
private bool _isLocalMuted;
/// <summary>True when the local user's camera is off in the active Teams call.</summary>
public bool IsLocalCameraOff
{
get => _isLocalCameraOff;
private set => SetField(ref _isLocalCameraOff, value);
}
private bool _isLocalCameraOff;
/// <summary>
/// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
/// when nothing's running. Useful for operators tracking show length.
@ -689,7 +705,11 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{
try
{
var inCall = TeamsControlBridge.IsInCall();
// 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(() =>
{
@ -698,6 +718,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
: "READY";
// Mute / camera state — only meaningful in-call.
IsLocalMuted = inCall && (snap.IsMuted ?? false);
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
// Auto-record on meeting transitions. False→True
// turns recording on; True→False turns it off.