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.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
|
|
||||||
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
|
<!-- Col 4 — Output name (mono, INLINE EDITABLE). The NDI source
|
||||||
will broadcast this participant as. -->
|
name TeamsISO will broadcast this participant as. Defaults
|
||||||
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True">
|
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>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock Text="{Binding OutputName}"
|
<TextBox Text="{Binding EditableOutputName, UpdateSourceTrigger=LostFocus, Mode=TwoWay}"
|
||||||
FontFamily="{StaticResource Wd.Font.Mono}"
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0"
|
||||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
CaretBrush="{DynamicResource Wd.Text.Primary}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
VerticalContentAlignment="Center"
|
||||||
|
ToolTip="NDI source name. Defaults to the speaker — type to override, clear to revert."/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
|
|
@ -1009,7 +1020,7 @@
|
||||||
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
|
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
|
||||||
FontFamily="{StaticResource Wd.Font.Mono}"
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
FontSize="11"/>
|
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}"
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace TeamsISO.App.Services;
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// User-editable template for the NDI source name a participant's ISO is
|
/// User-editable template for the NDI source name a participant's ISO is
|
||||||
/// published as. Default <c>"TEAMSISO_{guid}"</c> matches the original
|
/// published as. Default <c>"{name}"</c> renders the speaker's display name
|
||||||
/// hard-coded <c>DefaultOutputName</c> in <c>IsoController</c>; operators
|
/// directly, which is what downstream switchers want when they key on
|
||||||
/// can switch to <c>"TEAMSISO_{name}"</c> for human-readable output names
|
/// readable identifiers. Operators can override globally to
|
||||||
/// (recommended for downstream switchers that key on name patterns), or
|
/// <c>"TEAMSISO_{guid}"</c> for the legacy stable-id behavior, or
|
||||||
/// <c>"TEAMSISO_{machine}_{name}"</c> when multiple TeamsISO machines feed
|
/// <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"/>:
|
/// Tokens expanded in <see cref="Render"/>:
|
||||||
/// <c>{name}</c> participant display name, sanitized (alphanumeric + underscore)
|
/// <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>{machine}</c> sanitized PC hostname (Environment.MachineName)
|
||||||
/// <c>{timestamp}</c> current local time as <c>yyyyMMdd_HHmmss</c>
|
/// <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>.
|
/// Persisted to <c>%LOCALAPPDATA%\TeamsISO\output-name-template.txt</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class OutputNameTemplate
|
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 =>
|
private static string TemplatePath =>
|
||||||
Path.Combine(
|
Path.Combine(
|
||||||
|
|
@ -87,7 +107,30 @@ public static class OutputNameTemplate
|
||||||
|
|
||||||
// Final sanitize on the rendered result — protects against a template
|
// Final sanitize on the rendered result — protects against a template
|
||||||
// that includes literal characters NDI doesn't accept.
|
// 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)
|
private static string SanitizeForNdi(string s)
|
||||||
|
|
|
||||||
|
|
@ -486,10 +486,13 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Output-name template applied when the operator enables an ISO without
|
/// Output-name template applied when the operator enables an ISO without
|
||||||
/// a per-participant CustomName. Default <c>"TEAMSISO_{guid}"</c> matches
|
/// a per-participant CustomName. Default <c>"{name}"</c> renders the
|
||||||
/// the engine's hard-coded behavior; switch to <c>"TEAMSISO_{name}"</c>
|
/// speaker's display name directly (changed from the legacy
|
||||||
/// for human-readable NDI source names. See <see cref="OutputNameTemplate"/>
|
/// <c>"TEAMSISO_{guid}"</c> in 0.9.0-rc19, since downstream switchers
|
||||||
/// for the supported tokens.
|
/// 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>
|
/// </summary>
|
||||||
public string OutputNameTemplate
|
public string OutputNameTemplate
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -422,15 +422,20 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (SetField(ref _customName, value))
|
if (SetField(ref _customName, value))
|
||||||
|
{
|
||||||
OnPropertyChanged(nameof(OutputName));
|
OnPropertyChanged(nameof(OutputName));
|
||||||
|
OnPropertyChanged(nameof(EditableOutputName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The NDI source name TeamsISO will broadcast this participant as. Prefers
|
/// The NDI source name TeamsISO will broadcast this participant as. Prefers
|
||||||
/// the operator's <see cref="CustomName"/> when set; otherwise renders the
|
/// the operator's <see cref="CustomName"/> when set; otherwise renders the
|
||||||
/// engine's default template (typically <c>TEAMSISO_{guid}</c>). Bound by
|
/// active template (default <c>"{name}"</c>, falling back to
|
||||||
/// the v2 participants table's mono "output name" column.
|
/// <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>
|
/// </summary>
|
||||||
public string OutputName =>
|
public string OutputName =>
|
||||||
string.IsNullOrWhiteSpace(_customName)
|
string.IsNullOrWhiteSpace(_customName)
|
||||||
|
|
@ -440,6 +445,39 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
_participant.DisplayName)
|
_participant.DisplayName)
|
||||||
: _customName;
|
: _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; }
|
public AsyncRelayCommand ToggleIsoCommand { get; }
|
||||||
|
|
||||||
/// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>
|
/// <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(SourceMachine));
|
||||||
OnPropertyChanged(nameof(SourceFullName));
|
OnPropertyChanged(nameof(SourceFullName));
|
||||||
OnPropertyChanged(nameof(IsOnline));
|
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()
|
private async Task ToggleIsoAsync()
|
||||||
|
|
@ -479,10 +523,11 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Resolve the output name: explicit per-participant CustomName
|
// Resolve the output name: explicit per-participant CustomName
|
||||||
// wins; otherwise expand the operator's template (defaults to
|
// wins; otherwise expand the operator's template (default is
|
||||||
// "TEAMSISO_{guid}" which matches the engine's old hard-coded
|
// "{name}" since 0.9.0-rc19, with an empty-name fallback to
|
||||||
// behavior). Passing the rendered name to EnableIsoAsync as
|
// TEAMSISO_{guid} inside Render). Passing the rendered name
|
||||||
// customName overrides the engine's DefaultOutputName path.
|
// to EnableIsoAsync as customName overrides the engine's
|
||||||
|
// DefaultOutputName path.
|
||||||
var resolvedName = string.IsNullOrWhiteSpace(_customName)
|
var resolvedName = string.IsNullOrWhiteSpace(_customName)
|
||||||
? Services.OutputNameTemplate.Render(
|
? Services.OutputNameTemplate.Render(
|
||||||
Services.OutputNameTemplate.Get(),
|
Services.OutputNameTemplate.Get(),
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,34 @@ public class OutputNameTemplateTests
|
||||||
private static readonly Guid TestId = new("11223344-5566-7788-99aa-bbccddeeff00");
|
private static readonly Guid TestId = new("11223344-5566-7788-99aa-bbccddeeff00");
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Render_DefaultTemplate_ProducesGuidPrefix()
|
public void Render_DefaultTemplate_RendersSpeakerDisplayName()
|
||||||
{
|
{
|
||||||
var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, "Jane");
|
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");
|
name.Should().Be("TEAMSISO_11223344");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue