Highlight active speaker row with cyan left border
Some checks failed
CI / build-and-test (push) Has been cancelled

Visual cue for who's currently speaking — operators don't need to watch every VU bar. MainViewModel.OnStatsTick scans enabled participants once per tick, picks the loudest above a 0.05 floor (anti-flicker threshold), sets IsActiveSpeaker on the winner and clears on everyone else. DataGridRow DataTrigger swaps in a 3px cyan-accent left border + CyanMuted background tint when IsActiveSpeaker is true.

Plays well with sort modes: LoudestFirst makes the highlighted row always the topmost; other sort modes leave the row position alone, just paints the indicator.
This commit is contained in:
Zac Gaetano 2026-05-10 21:28:09 -04:00
parent 9db0875f9e
commit b0029a51bf
3 changed files with 45 additions and 0 deletions

View file

@ -740,6 +740,15 @@
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceElevated}"/>
</Trigger>
<!-- Active speaker highlight: set by MainViewModel at the 1Hz
stats tick based on DisplayedAudioLevel. Cyan left border
(3px) makes whoever's talking pop without changing the
background color (which IsMouseOver / IsSelected own). -->
<DataTrigger Binding="{Binding IsActiveSpeaker}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="BorderThickness" Value="3,0,0,1"/>
<Setter Property="Background" Value="{StaticResource Wd.Accent.CyanMuted}"/>
</DataTrigger>
</Style.Triggers>
</Style>

View file

@ -752,6 +752,29 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
}
// Active-speaker highlight: find the loudest enabled participant
// and mark their IsActiveSpeaker flag. Only one row at a time;
// ties broken by enumeration order (first one wins). Threshold of
// 0.05 prevents constant flicker between near-silent participants
// when nobody's really speaking.
ParticipantViewModel? loudest = null;
double loudestLevel = 0.05;
foreach (var p in Participants)
{
if (!p.IsEnabled) continue;
if (p.DisplayedAudioLevel > loudestLevel)
{
loudest = p;
loudestLevel = p.DisplayedAudioLevel;
}
}
foreach (var p in Participants)
{
var shouldHighlight = ReferenceEquals(p, loudest);
if (p.IsActiveSpeaker != shouldHighlight)
p.IsActiveSpeaker = shouldHighlight;
}
// If sort mode is LoudestFirst, refresh the view so the new audio
// peaks re-evaluate the sort. Skipped for the other sort modes
// since their keys (name, online state) don't change every tick —

View file

@ -44,6 +44,19 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary>True when <see cref="Thumbnail"/> is non-null. Bound to Visibility in XAML.</summary>
public bool HasThumbnail => _thumbnail is not null;
/// <summary>
/// True when this participant is currently the loudest among the live
/// set — set by MainViewModel at the 1Hz stats tick. Bound to a cyan
/// border accent on the DataGrid row so operators can spot who's
/// speaking without watching every VU bar individually.
/// </summary>
public bool IsActiveSpeaker
{
get => _isActiveSpeaker;
internal set => SetField(ref _isActiveSpeaker, value);
}
private bool _isActiveSpeaker;
public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null)
{
_controller = controller;