diff --git a/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs b/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs
index 565ac84..dbf2a24 100644
--- a/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs
+++ b/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs
@@ -5,64 +5,94 @@ namespace TeamsISO.Engine.Discovery;
///
/// Parses NDI source strings emitted by Microsoft Teams.
///
-/// Examples Teams emits:
-/// "MACHINE (Teams - Display Name)"
-/// "MACHINE (Teams)" — auto-mixed active speaker
+/// Teams has shipped two on-the-wire naming conventions for its NDI broadcast
+/// outputs. Both are recognized.
+///
+/// Legacy (older Teams desktop):
+/// "MACHINE (Teams)" — auto-mixed active-speaker output
/// "MACHINE (Teams Audio)" — audio-only mix
/// "MACHINE (Teams Screen Share)" — screen share
+/// "MACHINE (Teams - Display Name)" — per-participant
+///
+/// Current (the new Microsoft Teams desktop client, observed 2026):
+/// "MACHINE (MS Teams - Active Speaker)" — auto-mixed active-speaker output
+/// "MACHINE (MS Teams - Audio Mix)" — audio-only mix
+/// "MACHINE (MS Teams - Screen Share)" — screen share
+/// "MACHINE (MS Teams - (Local))" — the local user's own preview
+/// "MACHINE (MS Teams - Display Name)" — per-participant
+///
+/// "Microsoft Teams" is also accepted as a defensive future-proof brand prefix.
///
public static class NdiSourceParser
{
+ ///
+ /// Brand prefixes we recognize, ordered LONGEST FIRST so that "MS Teams"
+ /// is matched before the shorter "Teams" substring it contains.
+ ///
+ private static readonly string[] BrandPrefixes = { "Microsoft Teams", "MS Teams", "Teams" };
+
+ ///
+ /// Reserved suffix tokens that classify a source as a non-participant kind.
+ /// Includes both legacy spellings ("Audio") and current ("Audio Mix").
+ ///
+ private static readonly Dictionary ReservedSuffixes = new(StringComparer.Ordinal)
+ {
+ ["Active Speaker"] = NdiSourceKind.ActiveSpeaker,
+ ["Audio"] = NdiSourceKind.Audio,
+ ["Audio Mix"] = NdiSourceKind.Audio,
+ ["Screen Share"] = NdiSourceKind.ScreenShare,
+ };
+
public static NdiSource? Parse(string fullName)
{
- if (string.IsNullOrWhiteSpace(fullName))
- return null;
+ if (string.IsNullOrWhiteSpace(fullName)) return null;
+ if (!fullName.EndsWith(')')) return null;
- // Find the last '(' so machine names can themselves contain parens.
- var openParen = fullName.LastIndexOf('(');
- if (openParen <= 0 || !fullName.EndsWith(')'))
+ // Locate " (" by scanning for " (BRAND" with the longest
+ // brand first. Anchoring this way (rather than to the last '(') means
+ // machine names that themselves contain parens won't truncate the boundary,
+ // and display names that contain parens (e.g. "(Local)") survive intact
+ // because we extract them by string length, not by re-scanning for ')'.
+ var openParen = -1;
+ string? brand = null;
+ foreach (var candidate in BrandPrefixes)
{
- // Try outer parens for cases like "MACHINE (Teams - Smith, Bob (PM))"
- // by walking from the first '(' that opens a "Teams" inner.
- var firstParen = fullName.IndexOf(" (Teams", StringComparison.Ordinal);
- if (firstParen <= 0)
- return null;
- openParen = firstParen + 1;
- if (!fullName.EndsWith(')'))
- return null;
- }
- else
- {
- // Re-anchor to the first " (Teams" if present, so display names containing
- // their own parens (e.g. "Smith, Bob (PM)") don't get truncated.
- var firstParen = fullName.IndexOf(" (Teams", StringComparison.Ordinal);
- if (firstParen > 0)
- openParen = firstParen + 1;
+ var needle = " (" + candidate;
+ var i = fullName.IndexOf(needle, StringComparison.Ordinal);
+ if (i > 0)
+ {
+ openParen = i + 1;
+ brand = candidate;
+ break;
+ }
}
+ if (openParen < 0 || brand is null) return null;
var machine = fullName[..openParen].TrimEnd();
- if (machine.Length == 0)
- return null;
+ if (machine.Length == 0) return null;
+ // inner is the text between '(' and the trailing ')'.
var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim();
+ if (!inner.StartsWith(brand, StringComparison.Ordinal)) return null;
- if (!inner.StartsWith("Teams", StringComparison.Ordinal))
- return null;
+ var rest = inner[brand.Length..].TrimStart();
- if (inner == "Teams")
+ // Legacy "MACHINE (Teams)" — bare brand, no suffix → active speaker.
+ if (rest.Length == 0)
return new NdiSource(fullName, machine, NdiSourceKind.ActiveSpeaker, DisplayName: null);
- if (inner == "Teams Audio")
- return new NdiSource(fullName, machine, NdiSourceKind.Audio, DisplayName: null);
- if (inner == "Teams Screen Share")
- return new NdiSource(fullName, machine, NdiSourceKind.ScreenShare, DisplayName: null);
- const string prefix = "Teams - ";
- if (inner.StartsWith(prefix, StringComparison.Ordinal))
+ // Legacy: brand followed by a reserved word with no dash, e.g. "Teams Audio".
+ if (ReservedSuffixes.TryGetValue(rest, out var legacyKind))
+ return new NdiSource(fullName, machine, legacyKind, DisplayName: null);
+
+ // Current: brand followed by " - ".
+ if (rest.StartsWith('-'))
{
- var display = inner[prefix.Length..].Trim();
- if (display.Length == 0)
- return null;
- return new NdiSource(fullName, machine, NdiSourceKind.Participant, display);
+ var afterDash = rest[1..].TrimStart();
+ if (afterDash.Length == 0) return null;
+ if (ReservedSuffixes.TryGetValue(afterDash, out var taggedKind))
+ return new NdiSource(fullName, machine, taggedKind, DisplayName: null);
+ return new NdiSource(fullName, machine, NdiSourceKind.Participant, afterDash);
}
return null;
diff --git a/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs b/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs
index 8268d06..d9f21f3 100644
--- a/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs
+++ b/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs
@@ -6,9 +6,16 @@ namespace TeamsISO.Engine.Tests.Domain;
public class NdiSourceParserTests
{
[Theory]
+ // ----- Legacy "Teams" brand (older Teams desktop) -----
[InlineData("WORKSTATION-01 (Teams - Jane Doe)", "WORKSTATION-01", NdiSourceKind.Participant, "Jane Doe")]
[InlineData("PROD-PC (Teams - Élise O'Connor)", "PROD-PC", NdiSourceKind.Participant, "Élise O'Connor")]
[InlineData("HOST (Teams - Smith, Bob (PM))", "HOST", NdiSourceKind.Participant, "Smith, Bob (PM)")]
+ // ----- Current "MS Teams" brand (the new Teams desktop client) -----
+ [InlineData("WOOGLIN (MS Teams - Brendon Power)", "WOOGLIN", NdiSourceKind.Participant, "Brendon Power")]
+ [InlineData("WOOGLIN (MS Teams - (Local))", "WOOGLIN", NdiSourceKind.Participant, "(Local)")]
+ [InlineData("LAB-PC (MS Teams - Smith, Bob (PM))", "LAB-PC", NdiSourceKind.Participant, "Smith, Bob (PM)")]
+ // ----- Defensive future-proof "Microsoft Teams" brand -----
+ [InlineData("WORKSTATION (Microsoft Teams - Alex Rivera)", "WORKSTATION", NdiSourceKind.Participant, "Alex Rivera")]
public void Parse_Participant_ExtractsMachineAndDisplayName(
string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay)
{
@@ -22,9 +29,14 @@ public class NdiSourceParserTests
}
[Theory]
+ // ----- Legacy "Teams" forms -----
[InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)]
[InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)]
[InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)]
+ // ----- Current "MS Teams" forms (dash-prefixed reserved suffix) -----
+ [InlineData("WOOGLIN (MS Teams - Active Speaker)", NdiSourceKind.ActiveSpeaker)]
+ [InlineData("WOOGLIN (MS Teams - Audio Mix)", NdiSourceKind.Audio)]
+ [InlineData("WOOGLIN (MS Teams - Screen Share)", NdiSourceKind.ScreenShare)]
public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind)
{
var result = NdiSourceParser.Parse(fullName);