ui(iso): inline-editable Output name + default to speaker display name
Some checks failed
CI / build-and-test (push) Failing after 26s

This commit is contained in:
Zac Gaetano 2026-05-16 23:34:08 -04:00
parent dfdfa9e0e1
commit 99d6d80754
5 changed files with 155 additions and 29 deletions

View file

@ -637,17 +637,28 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
will broadcast this participant as. -->
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True">
<!-- Col 4 — Output name (mono, INLINE EDITABLE). The NDI source
name TeamsISO will broadcast this participant as. Defaults
to the speaker's display name; type to override per-row,
clear the field to revert to the default. EditableOutputName
handles both directions (see ParticipantViewModel comment).
UpdateSourceTrigger=LostFocus so we don't restart the NDI
sender on every keystroke — only when the operator
commits by tabbing away or pressing Enter. -->
<DataGridTemplateColumn Header="Output" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding OutputName}"
<TextBox Text="{Binding EditableOutputName, UpdateSourceTrigger=LostFocus, Mode=TwoWay}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Background="Transparent"
BorderThickness="0"
Padding="0"
Foreground="{DynamicResource Wd.Text.Secondary}"
CaretBrush="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
VerticalContentAlignment="Center"
ToolTip="NDI source name. Defaults to the speaker — type to override, clear to revert."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@ -1009,7 +1020,7 @@
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"/>
<TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: TEAMSISO_{guid}."
<TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: {name} — the speaker's display name."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"

View file

@ -1,16 +1,18 @@
using System.IO;
using System.Linq;
using System.Text;
namespace TeamsISO.App.Services;
/// <summary>
/// User-editable template for the NDI source name a participant's ISO is
/// published as. Default <c>"TEAMSISO_{guid}"</c> matches the original
/// hard-coded <c>DefaultOutputName</c> in <c>IsoController</c>; operators
/// can switch to <c>"TEAMSISO_{name}"</c> for human-readable output names
/// (recommended for downstream switchers that key on name patterns), or
/// published as. Default <c>"{name}"</c> renders the speaker's display name
/// directly, which is what downstream switchers want when they key on
/// readable identifiers. Operators can override globally to
/// <c>"TEAMSISO_{guid}"</c> for the legacy stable-id behavior, or
/// <c>"TEAMSISO_{machine}_{name}"</c> 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 <see cref="Render"/>:
/// <c>{name}</c> participant display name, sanitized (alphanumeric + underscore)
@ -18,11 +20,29 @@ namespace TeamsISO.App.Services;
/// <c>{machine}</c> sanitized PC hostname (Environment.MachineName)
/// <c>{timestamp}</c> current local time as <c>yyyyMMdd_HHmmss</c>
///
/// Empty-name fallback: if the rendered result is empty/whitespace (e.g.
/// template was <c>"{name}"</c> and the participant joined with no display
/// name yet), <see cref="Render"/> falls back to <c>TEAMSISO_{guid}</c> so
/// the NDI sender always has a usable, unique identifier.
///
/// Persisted to <c>%LOCALAPPDATA%\TeamsISO\output-name-template.txt</c>.
/// </summary>
public static class OutputNameTemplate
{
public const string DefaultTemplate = "TEAMSISO_{guid}";
/// <summary>
/// Default template — renders just the speaker's display name. Was
/// <c>"TEAMSISO_{guid}"</c> in pre-v1 builds; switched 2026-05-16 so
/// new installs get human-readable source names out of the box.
/// </summary>
public const string DefaultTemplate = "{name}";
/// <summary>
/// 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.
/// </summary>
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)

View file

@ -486,10 +486,13 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary>
/// Output-name template applied when the operator enables an ISO without
/// a per-participant CustomName. Default <c>"TEAMSISO_{guid}"</c> matches
/// the engine's hard-coded behavior; switch to <c>"TEAMSISO_{name}"</c>
/// for human-readable NDI source names. See <see cref="OutputNameTemplate"/>
/// for the supported tokens.
/// a per-participant CustomName. Default <c>"{name}"</c> renders the
/// speaker's display name directly (changed from the legacy
/// <c>"TEAMSISO_{guid}"</c> 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 <see cref="OutputNameTemplate"/> for the supported
/// tokens.
/// </summary>
public string OutputNameTemplate
{

View file

@ -422,15 +422,20 @@ public sealed class ParticipantViewModel : ObservableObject
set
{
if (SetField(ref _customName, value))
{
OnPropertyChanged(nameof(OutputName));
OnPropertyChanged(nameof(EditableOutputName));
}
}
}
/// <summary>
/// The NDI source name TeamsISO will broadcast this participant as. Prefers
/// the operator's <see cref="CustomName"/> when set; otherwise renders the
/// engine's default template (typically <c>TEAMSISO_{guid}</c>). Bound by
/// the v2 participants table's mono "output name" column.
/// active template (default <c>"{name}"</c>, falling back to
/// <c>TEAMSISO_{guid}</c> when the participant has no display name yet).
/// Bound by the v2 participants table's mono "output name" column for
/// read-only display contexts.
/// </summary>
public string OutputName =>
string.IsNullOrWhiteSpace(_customName)
@ -440,6 +445,39 @@ public sealed class ParticipantViewModel : ObservableObject
_participant.DisplayName)
: _customName;
/// <summary>
/// Two-way binding endpoint for the inline-editable Output column. Reads
/// resolve to whatever name will actually be broadcast (<see cref="OutputName"/>);
/// writes set <see cref="CustomName"/> 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.
/// </summary>
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; }
/// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>
@ -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(),

View file

@ -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");
}