Sort participants by Loudest (active speaker at top)
Some checks failed
CI / build-and-test (push) Failing after 43s

Adds a fourth participant sort mode: LoudestFirst, sorts by DisplayedAudioLevel descending so the current active speaker bubbles to the top of the DataGrid. Operators reacting to who's talking can see the active speaker without scanning the list.

Refresh-on-tick (1Hz) only fires when LoudestFirst is active — other sort modes don't change keys every tick so they skip the cost. ParticipantViewModel.DisplayedAudioLevel already has a decay envelope (max-of-new-or-decayed-old at 0.7 per tick), which prevents jittery reorder on every audio frame.

Persisted via the existing UIPreferences.ParticipantSort enum (new value tacked onto the end so older ui-prefs.json files default to JoinOrder cleanly).
This commit is contained in:
Zac Gaetano 2026-05-10 21:21:45 -04:00
parent 5c491c9d83
commit d6793d8d9c
2 changed files with 25 additions and 1 deletions

View file

@ -30,8 +30,10 @@ public static class UIPreferences
/// Sort modes for the participants DataGrid. <see cref="JoinOrder"/> is the default
/// and matches the engine's discovery order (operators with custom Stream Deck
/// layouts sometimes prefer Alphabetical for stability across meetings).
/// <see cref="LoudestFirst"/> resorts at the 1Hz stats tick so the active
/// speaker bubbles to the top — useful for operators reacting to who's talking.
/// </summary>
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst }
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst, LoudestFirst }
/// <summary>The on-disk shape. New fields added here become opt-in for older files via default values.</summary>
public sealed record Prefs(

View file

@ -65,6 +65,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// </summary>
public void SetSortMode(Services.UIPreferences.SortMode mode)
{
_currentSortMode = mode;
ParticipantsView.SortDescriptions.Clear();
switch (mode)
{
@ -78,9 +79,20 @@ public sealed class MainViewModel : ObservableObject, IDisposable
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
case Services.UIPreferences.SortMode.LoudestFirst:
// Sort by the displayed audio level (which already includes the
// decay envelope) so participants don't snap-reorder on every
// tiny audio frame. ParticipantsView.Refresh() at the stats
// tick re-evaluates the sort with the latest values.
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayedAudioLevel), ListSortDirection.Descending));
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
// JoinOrder: leave SortDescriptions empty.
}
}
private Services.UIPreferences.SortMode _currentSortMode = Services.UIPreferences.SortMode.JoinOrder;
/// <summary>
/// Live filter substring. Empty = show everyone. Matched case-insensitively
/// against display name. Setter refreshes the view immediately so the
@ -642,6 +654,16 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
}
// 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 —
// no need to pay the Refresh cost.
if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst)
{
try { ParticipantsView.Refresh(); }
catch { /* defensive — Refresh occasionally throws on collection mutations */ }
}
// Update footer badges. Recording count is "ISOs that have a recorder
// attached" — _controller.RecordingEnabled tells us the global toggle,
// but the actual recorder count = number of running pipelines while