feat(ui): empty-state, pipeline error/no-signal indicators, JetBrains Mono, tooltips
Some checks failed
CI / build-and-test (push) Failing after 30s

Four polish improvements aimed at production-floor usability.

1. Empty-state placeholder for the participants card. When Participants.Count == 0, the DataGrid is hidden in favor of a friendly 'Waiting for Teams' panel: faded dragon mark, headline, explainer, and a four-item checklist (Teams running? NDI broadcast on? Discovery group correct? Firewall clear?). New CountToVisibilityConverter (with optional 'empty' parameter to invert) drives both the placeholder and the DataGrid visibility from the same Participants.Count source.

2. Per-pipeline error / no-signal surfacing. IsoHealthStats grows an init-only State property populated from IsoPipeline.State. ParticipantViewModel.UpdateStats maps that to a StateLabel ('LIVE' / 'NO SIGNAL' / 'ERROR' / 'STARTING' / '—'). The ISO toggle button gains DataTriggers on StateLabel — coral-tinted '● ERROR' when the supervisor gives up, amber-tinted '● NO SIGNAL' when the slate threshold trips. Operators can see at a glance which pipelines are broken.

3. JetBrains Mono Variable v2.304 (OFL) bundled at Assets/Fonts/JetBrainsMono.ttf. Wd.Font.Mono now points at the embedded font so machine names, timecodes, and stat counters render in JetBrains Mono regardless of system fonts. Falls back to Cascadia Mono / Consolas if the resource is missing.

4. Tooltip pass over every interactive control in the settings panel (framerate / resolution / aspect / audio / discovery group / output group / hide-local checkbox / Apply button / per-row Output Name textbox / per-row ISO toggle). Operators learn affordances on hover instead of by trial and error.

Tests: 76/76 unit + 9/9 NDI integration green.
This commit is contained in:
Zac Gaetano 2026-05-08 19:32:19 -04:00
parent 0c82ac71f0
commit ab072979d8
8 changed files with 171 additions and 13 deletions

Binary file not shown.

View file

@ -0,0 +1,32 @@
using System.Collections;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace TeamsISO.App.Converters;
/// <summary>
/// Maps a collection (or its count) to <see cref="Visibility"/>. Pass
/// <c>"empty"</c> as the converter parameter to invert the sense (Visible when
/// count == 0). Used to swap an empty-state placeholder in for the participants
/// DataGrid when no Teams sources are visible yet.
/// </summary>
public sealed class CountToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var count = value switch
{
int n => n,
ICollection c => c.Count,
null => 0,
_ => 1, // anything else: treat as non-empty
};
var invert = string.Equals(parameter as string, "empty", StringComparison.OrdinalIgnoreCase);
var visible = invert ? count == 0 : count > 0;
return visible ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}

View file

@ -35,6 +35,7 @@
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:InitialsConverter x:Key="Initials"/>
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
</Window.Resources>
<Grid>
@ -352,8 +353,77 @@
<Border Grid.Row="2"
Style="{StaticResource Wd.Card}"
Padding="0">
<DataGrid ItemsSource="{Binding Participants}"
Margin="6,4,6,4">
<Grid>
<!-- Empty-state placeholder. Visible when Participants.Count == 0. -->
<StackPanel Visibility="{Binding Participants.Count, Converter={StaticResource CountToVis}, ConverterParameter=empty}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="460"
Margin="32,48">
<Image Source="/Assets/dragon-mark.png"
Width="64" Height="64"
Opacity="0.45"
HorizontalAlignment="Center"
Margin="0,0,0,20"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="Waiting for Teams"
Style="{StaticResource Wd.Text.Heading}"
FontSize="16"
HorizontalAlignment="Center"/>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"
Margin="0,8,0,20"
Text="No Teams participants discovered on the network yet. Once Teams broadcasts NDI sources, this list will populate within a few seconds."/>
<Border Style="{StaticResource Wd.Card}"
Background="{DynamicResource Wd.SurfaceElevated}"
Padding="16,12">
<StackPanel>
<TextBlock Text="QUICK CHECKLIST"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,0,0,8"/>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
FontSize="12"
Margin="0,0,0,4">
<Run Text="•"/>
<Run Text=" Microsoft Teams is running and a meeting is active"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
FontSize="12"
Margin="0,0,0,4">
<Run Text="•"/>
<Run Text=" NDI broadcast is enabled in Teams settings"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
FontSize="12"
Margin="0,0,0,4">
<Run Text="•"/>
<Run Text=" Discovery group in Settings matches Teams' broadcast group"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
FontSize="12">
<Run Text="•"/>
<Run Text=" Windows Firewall isn't blocking NDI multicast on the active network adapter"/>
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
<!-- DataGrid: visible only when Participants.Count > 0. -->
<DataGrid ItemsSource="{Binding Participants}"
Visibility="{Binding Participants.Count, Converter={StaticResource CountToVis}}"
Margin="6,4,6,4">
<DataGrid.Columns>
<DataGridTemplateColumn Header="Display Name" Width="2*">
<DataGridTemplateColumn.CellTemplate>
@ -424,7 +494,8 @@
<TextBox Text="{Binding CustomName, UpdateSourceTrigger=PropertyChanged}"
Padding="10,7"
Margin="0,0,12,0"
VerticalAlignment="Center"/>
VerticalAlignment="Center"
ToolTip="Override the auto-generated NDI output name (TEAMSISO_xxxxxxxx). Takes effect when ISO is enabled."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@ -433,7 +504,8 @@
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0">
Margin="0,0,12,0"
ToolTip="Toggle this participant's normalized NDI ISO output">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
<Setter Property="Content" Value="Enable"/>
@ -445,6 +517,19 @@
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Cyan}"/>
</DataTrigger>
<!-- Pipeline failed past retry budget — surface the error state in coral. -->
<DataTrigger Binding="{Binding StateLabel}" Value="ERROR">
<Setter Property="Content" Value="● ERROR"/>
<Setter Property="Background" Value="{DynamicResource Wd.Accent.CoralBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Coral}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Coral}"/>
</DataTrigger>
<!-- No frames received in slate-threshold window — amber warning. -->
<DataTrigger Binding="{Binding StateLabel}" Value="NO SIGNAL">
<Setter Property="Content" Value="● NO SIGNAL"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Status.Warn}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Status.Warn}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsProcessing}" Value="True">
<Setter Property="Content" Value="…"/>
<Setter Property="IsEnabled" Value="False"/>
@ -458,6 +543,7 @@
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border>
</Grid>
</DockPanel>
@ -488,7 +574,8 @@
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableFramerates}"
SelectedItem="{Binding Settings.Framerate}">
SelectedItem="{Binding Settings.Framerate}"
ToolTip="The framerate every ISO output is normalized to. Click Apply to commit.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
@ -500,7 +587,8 @@
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableResolutions}"
SelectedItem="{Binding Settings.Resolution}">
SelectedItem="{Binding Settings.Resolution}"
ToolTip="Output resolution. Source frames smaller than this are scaled up; larger frames are scaled down.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
@ -512,7 +600,8 @@
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAspectModes}"
SelectedItem="{Binding Settings.Aspect}">
SelectedItem="{Binding Settings.Aspect}"
ToolTip="How to fit non-matching aspect ratios. Pillarbox = bars on the sides; Letterbox = bars top/bottom; Stretch = distort to fill.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
@ -524,7 +613,8 @@
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAudioModes}"
SelectedItem="{Binding Settings.Audio}">
SelectedItem="{Binding Settings.Audio}"
ToolTip="Audio routing for ISO outputs. Auto = isolated when available, fall back to mixed; Isolated = participant audio only; Mixed = full meeting mix.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
@ -540,7 +630,8 @@
<TextBlock Text="Discovery group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<TextBox Text="{Binding Settings.DiscoveryGroups, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Text="{Binding Settings.DiscoveryGroups, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Comma-separated NDI group names the engine subscribes to. Empty = Public (default)."/>
<TextBlock Text="Receive sources from this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
@ -550,7 +641,8 @@
<TextBlock Text="Output group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBox Text="{Binding Settings.OutputGroups, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Text="{Binding Settings.OutputGroups, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Comma-separated NDI group(s) TeamsISO's normalized ISO outputs broadcast on. Empty = Public."/>
<TextBlock Text="Broadcast TeamsISO outputs on this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
@ -593,6 +685,7 @@
<CheckBox Content="Hide my self-preview from participants"
IsChecked="{Binding Settings.HideLocalSelf}"
ToolTip="Filters out the '(Local)' source — your own preview that Teams broadcasts on the same machine — so you don't accidentally route it as an ISO."
Margin="0,10,0,0"/>
<Button Style="{StaticResource Wd.Button.Primary}"
@ -600,7 +693,8 @@
Command="{Binding Settings.ApplyCommand}"
HorizontalAlignment="Stretch"
Margin="0,28,0,0"
Padding="0,11"/>
Padding="0,11"
ToolTip="Persist all settings to %APPDATA%\TeamsISO\config.json. Group changes apply on next launch."/>
</StackPanel>
</ScrollViewer>
</Border>

View file

@ -25,6 +25,11 @@
from 100 (Thin) to 900 (Black) so we don't ship a directory of duplicates.
-->
<Resource Include="Assets\Fonts\Inter.ttf" />
<!--
JetBrains Mono Variable v2.304 (OFL). Used for machine names, source IDs,
and stat counters where a fixed-width font reads better than Inter.
-->
<Resource Include="Assets\Fonts\JetBrainsMono.ttf" />
</ItemGroup>
</Project>

View file

@ -76,7 +76,7 @@
Segoe UI if the resource is somehow missing.
-->
<FontFamily x:Key="Wd.Font.Sans">pack://application:,,,/Assets/Fonts/#Inter, Inter, Segoe UI Variable Display, Segoe UI, sans-serif</FontFamily>
<FontFamily x:Key="Wd.Font.Mono">JetBrains Mono, Cascadia Mono, Consolas, monospace</FontFamily>
<FontFamily x:Key="Wd.Font.Mono">pack://application:,,,/Assets/Fonts/#JetBrains Mono, JetBrains Mono, Cascadia Mono, Consolas, monospace</FontFamily>
<Style x:Key="Wd.Text.Title" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>

View file

@ -51,6 +51,15 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary>
public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
private string _stateLabel = "—";
private string _stateColor = "Wd.Text.Tertiary";
/// <summary>Human-readable pipeline state ("Receiving", "Error", "—").</summary>
public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }
/// <summary>Resource key of the brush to color the state pill with.</summary>
public string StateColor { get => _stateColor; set => SetField(ref _stateColor, value); }
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
@ -73,6 +82,15 @@ public sealed class ParticipantViewModel : ObservableObject
IncomingFps = stats.IncomingFps > 0
? $"{stats.IncomingFps:0.0} fps"
: "—";
(StateLabel, StateColor) = stats.State switch
{
IsoState.Receiving => ("LIVE", "Wd.Status.Live"),
IsoState.Sending => ("LIVE", "Wd.Status.Live"),
IsoState.NoSignal => ("NO SIGNAL", "Wd.Status.Warn"),
IsoState.Error => ("ERROR", "Wd.Status.Error"),
IsoState.Idle => (IsEnabled ? "STARTING" : "—", "Wd.Text.Tertiary"),
_ => ("—", "Wd.Text.Tertiary"),
};
}
public bool IsProcessing

View file

@ -12,4 +12,10 @@ public sealed record IsoHealthStats(
{
public static readonly IsoHealthStats Empty =
new(0, 0, 0, 0, null, 0, 0, 0);
/// <summary>
/// Pipeline lifecycle state. <see cref="IsoState.Idle"/> when no pipeline is
/// running; otherwise reflects the supervisor's current view.
/// </summary>
public IsoState State { get; init; } = IsoState.Idle;
}

View file

@ -79,7 +79,10 @@ public sealed class IsoPipeline : IAsyncDisposable
LastFrameAt: lastAt,
IncomingFps: ComputeFps(),
IncomingWidth: w,
IncomingHeight: h);
IncomingHeight: h)
{
State = State,
};
}
/// <summary>