From 99d6d8075405a40e4a0ab2271f2189d412323d13 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sat, 16 May 2026 23:34:08 -0400 Subject: [PATCH] ui(iso): inline-editable Output name + default to speaker display name --- src/TeamsISO.App/MainWindow.xaml | 31 ++++++---- .../Services/OutputNameTemplate.cs | 57 ++++++++++++++++--- .../ViewModels/GlobalSettingsViewModel.cs | 11 ++-- .../ViewModels/ParticipantViewModel.cs | 57 +++++++++++++++++-- .../Services/OutputNameTemplateTests.cs | 28 ++++++++- 5 files changed, 155 insertions(+), 29 deletions(-) diff --git a/src/TeamsISO.App/MainWindow.xaml b/src/TeamsISO.App/MainWindow.xaml index a23a5ff..b5e0df9 100644 --- a/src/TeamsISO.App/MainWindow.xaml +++ b/src/TeamsISO.App/MainWindow.xaml @@ -637,17 +637,28 @@ - - + + - + @@ -1009,7 +1020,7 @@ - /// User-editable template for the NDI source name a participant's ISO is -/// published as. Default "TEAMSISO_{guid}" matches the original -/// hard-coded DefaultOutputName in IsoController; operators -/// can switch to "TEAMSISO_{name}" for human-readable output names -/// (recommended for downstream switchers that key on name patterns), or +/// published as. Default "{name}" renders the speaker's display name +/// directly, which is what downstream switchers want when they key on +/// readable identifiers. Operators can override globally to +/// "TEAMSISO_{guid}" for the legacy stable-id behavior, or /// "TEAMSISO_{machine}_{name}" when multiple TeamsISO machines feed -/// the same NDI network. +/// the same NDI network and you want the source name to carry both. +/// Per-participant overrides take priority over whatever template is set. /// /// Tokens expanded in : /// {name} participant display name, sanitized (alphanumeric + underscore) @@ -18,11 +20,29 @@ namespace TeamsISO.App.Services; /// {machine} sanitized PC hostname (Environment.MachineName) /// {timestamp} current local time as yyyyMMdd_HHmmss /// +/// Empty-name fallback: if the rendered result is empty/whitespace (e.g. +/// template was "{name}" and the participant joined with no display +/// name yet), falls back to TEAMSISO_{guid} so +/// the NDI sender always has a usable, unique identifier. +/// /// Persisted to %LOCALAPPDATA%\TeamsISO\output-name-template.txt. /// public static class OutputNameTemplate { - public const string DefaultTemplate = "TEAMSISO_{guid}"; + /// + /// Default template — renders just the speaker's display name. Was + /// "TEAMSISO_{guid}" in pre-v1 builds; switched 2026-05-16 so + /// new installs get human-readable source names out of the box. + /// + public const string DefaultTemplate = "{name}"; + + /// + /// Stable fallback used when the rendered template produces an empty + /// string (typically because a participant has no display name yet). + /// Mirrors the engine's legacy DefaultOutputName so the NDI sender is + /// always uniquely identifiable. + /// + private const string EmptyNameFallback = "TEAMSISO_{guid}"; private static string TemplatePath => Path.Combine( @@ -87,7 +107,30 @@ public static class OutputNameTemplate // Final sanitize on the rendered result — protects against a template // that includes literal characters NDI doesn't accept. - return SanitizeForNdi(result); + var sanitized = SanitizeForNdi(result); + + // Empty-name fallback. The default template "{name}" can render to + // an unusable result for participants whose DisplayName hasn't been + // populated yet (Teams sometimes delivers the displayName a tick + // after the participant join event). Two failure modes to catch: + // + // • DisplayName == "" → "{name}" expands to "" → sanitized "". + // • DisplayName == " " → "{name}" expands to "___" because the + // sanitizer converts whitespace to underscores. + // + // Neither is a meaningful NDI source identifier, so we substitute + // TEAMSISO_{guid}. The Any(char.IsLetterOrDigit) check covers both + // cases — anything without at least one alphanumeric is unusable. + // We apply this AFTER token expansion (not on the raw input) so a + // template like "PFX_{name}" with empty displayName still works: + // it renders to "PFX_" which contains alphanumerics and is left + // alone. + if (string.IsNullOrWhiteSpace(sanitized) || !sanitized.Any(char.IsLetterOrDigit)) + { + sanitized = SanitizeForNdi(EmptyNameFallback.Replace("{guid}", guid)); + } + + return sanitized; } private static string SanitizeForNdi(string s) diff --git a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs index 4439602..f7007c0 100644 --- a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs +++ b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs @@ -486,10 +486,13 @@ public sealed class GlobalSettingsViewModel : ObservableObject /// /// Output-name template applied when the operator enables an ISO without - /// a per-participant CustomName. Default "TEAMSISO_{guid}" matches - /// the engine's hard-coded behavior; switch to "TEAMSISO_{name}" - /// for human-readable NDI source names. See - /// for the supported tokens. + /// a per-participant CustomName. Default "{name}" renders the + /// speaker's display name directly (changed from the legacy + /// "TEAMSISO_{guid}" in 0.9.0-rc19, since downstream switchers + /// almost always want human-readable identifiers). Switch back to a + /// guid-based template if you need stable IDs that survive participant + /// name changes. See for the supported + /// tokens. /// public string OutputNameTemplate { diff --git a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs index 712f89a..1ecaf3b 100644 --- a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs +++ b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs @@ -422,15 +422,20 @@ public sealed class ParticipantViewModel : ObservableObject set { if (SetField(ref _customName, value)) + { OnPropertyChanged(nameof(OutputName)); + OnPropertyChanged(nameof(EditableOutputName)); + } } } /// /// The NDI source name TeamsISO will broadcast this participant as. Prefers /// the operator's when set; otherwise renders the - /// engine's default template (typically TEAMSISO_{guid}). Bound by - /// the v2 participants table's mono "output name" column. + /// active template (default "{name}", falling back to + /// TEAMSISO_{guid} when the participant has no display name yet). + /// Bound by the v2 participants table's mono "output name" column for + /// read-only display contexts. /// public string OutputName => string.IsNullOrWhiteSpace(_customName) @@ -440,6 +445,39 @@ public sealed class ParticipantViewModel : ObservableObject _participant.DisplayName) : _customName; + /// + /// Two-way binding endpoint for the inline-editable Output column. Reads + /// resolve to whatever name will actually be broadcast (); + /// writes set with a couple of UX niceties: + /// + /// • Clearing the field (empty / whitespace) reverts to the template + /// default — the user doesn't have to remember the template syntax to + /// "undo" a customization. + /// + /// • Typing a value that exactly matches the resolved default is treated + /// as a no-op (CustomName stays empty), so the participant continues + /// to follow the template when their display name changes upstream. + /// Without this, typing the auto-suggested value would silently + /// "pin" the participant to a stale name forever. + /// + public string EditableOutputName + { + get => OutputName; + set + { + var trimmed = (value ?? string.Empty).Trim(); + var defaultRendered = Services.OutputNameTemplate.Render( + Services.OutputNameTemplate.Get(), + _participant.Id, + _participant.DisplayName); + + CustomName = string.IsNullOrWhiteSpace(trimmed) || + string.Equals(trimmed, defaultRendered, StringComparison.Ordinal) + ? string.Empty + : trimmed; + } + } + public AsyncRelayCommand ToggleIsoCommand { get; } /// Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor. @@ -464,6 +502,12 @@ public sealed class ParticipantViewModel : ObservableObject OnPropertyChanged(nameof(SourceMachine)); OnPropertyChanged(nameof(SourceFullName)); OnPropertyChanged(nameof(IsOnline)); + // OutputName/EditableOutputName both derive from _participant.DisplayName + // when no per-participant CustomName is set — re-notify so the Output + // column tracks upstream Teams name changes for participants who + // haven't been manually renamed. + OnPropertyChanged(nameof(OutputName)); + OnPropertyChanged(nameof(EditableOutputName)); } private async Task ToggleIsoAsync() @@ -479,10 +523,11 @@ public sealed class ParticipantViewModel : ObservableObject else { // Resolve the output name: explicit per-participant CustomName - // wins; otherwise expand the operator's template (defaults to - // "TEAMSISO_{guid}" which matches the engine's old hard-coded - // behavior). Passing the rendered name to EnableIsoAsync as - // customName overrides the engine's DefaultOutputName path. + // wins; otherwise expand the operator's template (default is + // "{name}" since 0.9.0-rc19, with an empty-name fallback to + // TEAMSISO_{guid} inside Render). Passing the rendered name + // to EnableIsoAsync as customName overrides the engine's + // DefaultOutputName path. var resolvedName = string.IsNullOrWhiteSpace(_customName) ? Services.OutputNameTemplate.Render( Services.OutputNameTemplate.Get(), diff --git a/src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs b/src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs index acbe68b..368952f 100644 --- a/src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs +++ b/src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs @@ -15,10 +15,34 @@ public class OutputNameTemplateTests private static readonly Guid TestId = new("11223344-5566-7788-99aa-bbccddeeff00"); [Fact] - public void Render_DefaultTemplate_ProducesGuidPrefix() + public void Render_DefaultTemplate_RendersSpeakerDisplayName() { var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, "Jane"); - // Default is "TEAMSISO_{guid}" → first 8 hex of TestId, uppercase. + // Default is "{name}" since 0.9.0-rc19 — produces the speaker name + // directly so downstream switchers see human-readable identifiers. + // Previously was "TEAMSISO_{guid}"; see DefaultTemplate's xmldoc. + name.Should().Be("Jane"); + } + + [Fact] + public void Render_DefaultTemplate_EmptyName_FallsBackToGuidPrefix() + { + // The "{name}" default would render to an empty string for a + // participant with no display name yet (Teams sometimes delivers + // DisplayName a tick after the join event). The empty-name + // fallback substitutes TEAMSISO_{guid} so the NDI sender is + // always uniquely identifiable. Without this, the engine would + // throw on an empty sender name. + var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, ""); + name.Should().Be("TEAMSISO_11223344"); + } + + [Fact] + public void Render_DefaultTemplate_WhitespaceName_FallsBackToGuidPrefix() + { + // Mirror of the empty-name case — whitespace-only display names + // sanitize down to empty and should trigger the same fallback. + var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, " "); name.Should().Be("TEAMSISO_11223344"); }