IN-CALL bar shows MUTED / CAM OFF pills
Some checks failed
CI / build-and-test (push) Failing after 34s
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:
parent
33f145624e
commit
61dce2eecd
3 changed files with 137 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue