ui(iso): inline-editable Output name + default to speaker display name
Some checks failed
CI / build-and-test (push) Failing after 26s
Some checks failed
CI / build-and-test (push) Failing after 26s
This commit is contained in:
parent
dfdfa9e0e1
commit
99d6d80754
5 changed files with 155 additions and 29 deletions
|
|
@ -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}"
|
||||
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<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"
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue