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"/>
|
VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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}"
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
Command="{Binding ToggleMuteCommand}"
|
Command="{Binding ToggleMuteCommand}"
|
||||||
Padding="14,6"
|
Padding="14,6"
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,86 @@ 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>
|
||||||
|
/// 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>
|
/// <summary>
|
||||||
/// True if Teams is currently in an active call. The Leave / Hang-up
|
/// 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,
|
/// 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>
|
/// <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);
|
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>
|
/// <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.
|
||||||
|
|
@ -689,7 +705,11 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
try
|
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;
|
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
|
||||||
_dispatcher.InvokeAsync(() =>
|
_dispatcher.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -698,6 +718,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
TeamsMeetingState = inCall
|
TeamsMeetingState = inCall
|
||||||
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
|
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
|
||||||
: "READY";
|
: "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
|
// Auto-record on meeting transitions. False→True
|
||||||
// turns recording on; True→False turns it off.
|
// turns recording on; True→False turns it off.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue