using TeamsISO.Engine.Domain; namespace TeamsISO.Engine.Discovery; /// /// Maintains the operator-facing participant list, applying the rename heuristic /// from the v1 spec: if a participant source disappears and another participant /// source with the same MachineName appears within , /// the existing transfers to the new source. /// public sealed class ParticipantTracker { private readonly TimeSpan _renameWindow; private readonly Func _now; private readonly List _participants = new(); private readonly List _recentlyRemoved = new(); public ParticipantTracker(TimeSpan renameWindow, Func now) { _renameWindow = renameWindow; _now = now; } public IReadOnlyList Participants => _participants.Select(m => m.ToRecord()).ToList(); public void Apply(DiscoveryEvent ev) { switch (ev) { case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.Participant: HandleAdded(a.Source); break; case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.ActiveSpeaker: HandleAutoMixAdded(a.Source); break; case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.Participant: HandleRemoved(r.Source); break; case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.ActiveSpeaker: HandleAutoMixRemoved(r.Source); break; } } private void HandleAdded(NdiSource source) { var now = _now(); PruneRecentlyRemoved(now); // Idempotency guard: if the same FullName is already tracked (because the // operator hit Refresh and discovery is re-emitting everything), refresh // LastSeen + DisplayName in place instead of minting a duplicate row. var alreadyLive = _participants.FirstOrDefault(p => p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName); if (alreadyLive is not null) { alreadyLive.DisplayName = source.DisplayName!; alreadyLive.CurrentSource = source; alreadyLive.LastSeen = now; return; } var match = _recentlyRemoved.FirstOrDefault(rr => rr.MachineName == source.MachineName); if (match is not null) { var existing = _participants.First(p => p.Id == match.Id); existing.DisplayName = source.DisplayName!; existing.CurrentSource = source; existing.LastSeen = now; _recentlyRemoved.Remove(match); return; } _participants.Add(new MutableParticipant( Id: Guid.NewGuid(), DisplayName: source.DisplayName!, CurrentSource: source, FirstSeen: now, LastSeen: now)); } /// /// Surfaces Teams' auto-mix output (the "Active Speaker" feed in current Teams, /// or the legacy bare "(Teams)" source) as a special row in the participant list /// with the synthetic display name "Active Speaker". The operator can then route /// it to its own normalized ISO via the same enable toggle every other participant /// uses. Id is deterministic from "auto-mix:<machine>" so the row's identity is /// stable across discovery churn. /// private void HandleAutoMixAdded(NdiSource source) { var now = _now(); PruneRecentlyRemoved(now); var stableId = DeterministicGuid("auto-mix:" + source.MachineName); var existing = _participants.FirstOrDefault(p => p.Id == stableId); if (existing is not null) { existing.CurrentSource = source; existing.LastSeen = now; return; } _participants.Add(new MutableParticipant( Id: stableId, DisplayName: "Active Speaker", CurrentSource: source, FirstSeen: now, LastSeen: now)); } /// /// Stable Guid derived from a string. Used to give the auto-mix synthetic /// participant a consistent Id across discovery cycles (so re-add doesn't /// duplicate the row, and the operator's ISO assignment persists). /// private static Guid DeterministicGuid(string input) { var hash = System.Security.Cryptography.SHA1.HashData(System.Text.Encoding.UTF8.GetBytes(input)); var bytes = new byte[16]; Buffer.BlockCopy(hash, 0, bytes, 0, 16); // Set the version (5) and variant bits per RFC 4122 so the value is a valid v5 GUID. bytes[6] = (byte)((bytes[6] & 0x0F) | 0x50); bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); return new Guid(bytes); } private void HandleRemoved(NdiSource source) { var now = _now(); var existing = _participants.FirstOrDefault(p => p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName); if (existing is null) return; existing.CurrentSource = null; _recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now)); } /// /// Removes the synthetic auto-mix row's CurrentSource. Crucially does NOT add to /// — the auto-mix row's identity is already stable /// via the deterministic v5 GUID, so re-add restores it without needing the /// rename-window heuristic, and we must not let an active-speaker disappearance /// poison the rename matcher for a Participant joining the same machine within /// the window. /// private void HandleAutoMixRemoved(NdiSource source) { var stableId = DeterministicGuid("auto-mix:" + source.MachineName); var existing = _participants.FirstOrDefault(p => p.Id == stableId); if (existing is null) return; existing.CurrentSource = null; existing.LastSeen = _now(); } private void PruneRecentlyRemoved(DateTimeOffset now) { _recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow); } private sealed class MutableParticipant { public Guid Id { get; init; } public string DisplayName { get; set; } public NdiSource? CurrentSource { get; set; } public DateTimeOffset FirstSeen { get; init; } public DateTimeOffset LastSeen { get; set; } public MutableParticipant(Guid Id, string DisplayName, NdiSource? CurrentSource, DateTimeOffset FirstSeen, DateTimeOffset LastSeen) { this.Id = Id; this.DisplayName = DisplayName; this.CurrentSource = CurrentSource; this.FirstSeen = FirstSeen; this.LastSeen = LastSeen; } public Participant ToRecord() => new(Id, DisplayName, CurrentSource, FirstSeen, LastSeen); } private sealed record RecentlyRemoved(Guid Id, string MachineName, DateTimeOffset RemovedAt); }