Compare commits

...

4 commits

Author SHA1 Message Date
6cac486fbe feat(ui): rebrand to Wild Dragon + Microsoft Teams layout
Some checks failed
CI / build-and-test (push) Failing after 33s
Replaces the Stone theme with Wild Dragon branding (canvas #0A0A0A, accent cyan #97EDF0, secondary #9AE0FD, coral alert #FB819C — sourced from wilddragon.net) and reorganizes MainWindow into a Microsoft Teams-style three-column layout: a 72px left rail (logo + Participants/Settings nav + engine-status indicator), a center content area (header + participants card), and a right settings panel.

Adds InitialsConverter so participant avatars render real initials (Brendon Power -> 'BP', '(Local)' -> 'L') instead of a generic glyph. Drops the obsolete StoneTheme.xaml; the project now ships exactly one theme dictionary.

Typography: Inter (with Segoe UI Variable Display fallback) for the sans stack, JetBrains Mono (Cascadia Mono fallback) for machine names and timecodes — matching the wilddragon.net site.

Verified live against the running Teams meeting: app launches, participant 'Brendon Power' displays with avatar, settings panel surfaces NDI groups + Hide-Local toggle, engine status pill shows green/live.
2026-05-08 00:08:39 -04:00
53c06a9af9 feat(ui): single-instance enforcement via per-user named mutex
Two simultaneous TeamsISO processes contend over the NDI runtime, the same default sender names, and %APPDATA%\\TeamsISO\\config.json — observed during testing when launchers / shortcuts produced duplicate windows. Add a Local namespace per-user-keyed mutex (Local\\WildDragon.TeamsISO.SingleInstance.<username>) at startup; if a second instance can't claim it, broadcast a registered window message ('WildDragon.TeamsISO.BringToFront') and Shutdown(0). The running instance subscribes to that message via ComponentDispatcher.ThreadFilterMessage and surfaces its main window when received.

Per-user keying lets two different Windows users on the same machine each run their own TeamsISO. Mutex is released and disposed on OnExit.

Verified: Start-Process the exe twice in a row -> only one process remains, with the original window surfaced.
2026-05-07 23:59:47 -04:00
b542d01835 feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.

Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.

Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
909237f454 feat(ndi): plumb NDI groups (discovery + output) through the engine
Adds an NdiGroupSettings record carrying optional comma-separated NDI group lists for the finder and the senders. Extends INdiInterop.CreateFinder / CreateSender with optional groups arguments and populates NDIlib_find_create_t.p_groups and NDIlib_send_create_t.p_groups via P/Invoke. IsoController reads the settings on construction, threads DiscoveryGroups into NdiDiscoveryService and OutputGroups into IsoPipelineConfig, and exposes SetGroupSettingsAsync for runtime updates (group changes apply on next process restart so live pipelines aren't orphaned).

This unblocks the 'transcoder' topology where Teams broadcasts NDI on a private group (e.g. teamsiso-input) and TeamsISO re-emits clean normalized streams on Public — keeping raw, wrong-framerate Teams sources off the production network.

EngineConfig schema is JSON-back-compat: existing config.json files (no NdiGroups field) deserialize with NdiGroups=null and load as NdiGroupSettings.Default. UI surface for these settings comes in a follow-up.

Tests: 72/72 passing (was 69) — added IsoController coverage that group settings are read from ConfigStore on startup, passed to the finder, threaded into per-pipeline config, and round-trip through SetGroupSettingsAsync/Save/Load.
2026-05-07 23:48:49 -04:00
20 changed files with 1312 additions and 130 deletions

View file

@ -1,5 +1,11 @@
<Application x:Class="TeamsISO.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources/>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Themes/WildDragonTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View file

@ -1,5 +1,7 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels;
@ -14,15 +16,63 @@ namespace TeamsISO.App;
public partial class App : Application
{
/// <summary>
/// Per-user mutex name. Including the SID-equivalent (the username) ensures two
/// different Windows users can each run TeamsISO on the same machine, while one
/// user can't spawn duplicate instances that would contend over the NDI runtime
/// and the shared %APPDATA%\TeamsISO\config.json.
/// </summary>
private static readonly string SingleInstanceMutexName =
$"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
private System.Threading.Mutex? _singleInstanceMutex;
private ILoggerFactory? _loggerFactory;
private NdiInteropPInvoke? _interop;
private IsoController? _controller;
private MainViewModel? _viewModel;
[DllImport("user32.dll")]
private static extern uint RegisterWindowMessageW(string lpString);
[DllImport("user32.dll")]
private static extern int PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern int SendNotifyMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
private const IntPtr HWND_BROADCAST = -1;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Single-instance gate: if another TeamsISO is already running for this user,
// broadcast the bring-to-front message and exit silently. This prevents the
// NDI/config contention seen during testing where two finders, two senders
// with the same default name, and two writers to config.json all raced.
bool createdNew;
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out createdNew);
if (!createdNew)
{
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
if (bringToFront != 0)
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
Shutdown(0);
return;
}
// Listen for the broadcast — if a *new* instance launches and finds us already
// running, it'll send this message; we surface our window in response.
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
ComponentDispatcher.ThreadFilterMessage += (ref System.Windows.Interop.MSG msg, ref bool handled) =>
{
if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
{
if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal;
MainWindow.Activate();
MainWindow.Topmost = true;
MainWindow.Topmost = false;
handled = true;
}
};
try
{
_loggerFactory = EngineLogging.CreateConsole(LogLevel.Information);
@ -102,6 +152,11 @@ public partial class App : Application
{
// Best-effort shutdown
}
finally
{
try { _singleInstanceMutex?.ReleaseMutex(); } catch { /* not owned */ }
_singleInstanceMutex?.Dispose();
}
base.OnExit(e);
}
}

View file

@ -0,0 +1,30 @@
using System.Globalization;
using System.Windows.Data;
namespace TeamsISO.App.Converters;
/// <summary>
/// Converts a display name to up to two uppercase initials for an avatar bubble.
/// "Brendon Power" → "BP". "(Local)" → "L". Falls back to "·" for empty inputs.
/// </summary>
public sealed class InitialsConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var s = value as string;
if (string.IsNullOrWhiteSpace(s)) return "·";
// Strip surrounding parens / punctuation that would otherwise become
// useless initials (e.g. "(Local)" should yield "L", not "(").
var cleaned = new string(s.Where(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c)).ToArray()).Trim();
if (cleaned.Length == 0) return "·";
var parts = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0) return "·";
if (parts.Length == 1) return char.ToUpperInvariant(parts[0][0]).ToString();
return $"{char.ToUpperInvariant(parts[0][0])}{char.ToUpperInvariant(parts[^1][0])}";
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View file

@ -3,60 +3,355 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
Title="TeamsISO" Height="700" Width="1100"
Background="#202225" Foreground="#E8E8E8">
Title="TeamsISO"
Height="780" Width="1280"
MinHeight="640" MinWidth="1080"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVis" />
<conv:EnumDescriptionConverter x:Key="EnumDesc" />
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#E8E8E8"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="13"/>
</Style>
<Style TargetType="Button">
<Setter Property="Padding" Value="10,4"/>
<Setter Property="Margin" Value="0,4,0,0"/>
<Setter Property="Background" Value="#3F4147"/>
<Setter Property="Foreground" Value="#E8E8E8"/>
<Setter Property="BorderBrush" Value="#5A5C63"/>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Margin" Value="0,2,0,8"/>
</Style>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:InitialsConverter x:Key="Initials"/>
</Window.Resources>
<DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="72"/> <!-- Left rail -->
<ColumnDefinition Width="*"/> <!-- Main content -->
<ColumnDefinition Width="380"/> <!-- Settings panel -->
</Grid.ColumnDefinitions>
<!-- Alert banner -->
<Border DockPanel.Dock="Top"
Background="#7A2B2B"
Padding="12,8"
Visibility="{Binding AlertBanner.IsVisible, Converter={StaticResource BoolToVis}}">
<DockPanel>
<Button DockPanel.Dock="Right"
Content="Dismiss"
Command="{Binding AlertBanner.DismissCommand}"
Margin="12,0,0,0"/>
<TextBlock Text="{Binding AlertBanner.Message}"
FontWeight="SemiBold"
VerticalAlignment="Center"/>
<!-- ════════════════════════════════════════════════════════════════
LEFT RAIL (Teams-style nav)
════════════════════════════════════════════════════════════════ -->
<Border Grid.Column="0"
Background="{DynamicResource Wd.Rail}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,1,0">
<DockPanel LastChildFill="False">
<!-- Wild Dragon logo: cyan circular mark with stylized "W" inside -->
<Border DockPanel.Dock="Top"
Width="40" Height="40"
Margin="0,16,0,8"
HorizontalAlignment="Center"
CornerRadius="10"
Background="{DynamicResource Wd.Accent.CyanMuted}"
BorderBrush="{DynamicResource Wd.Accent.Cyan}"
BorderThickness="1">
<Path Data="M 10,8 L 14,24 L 17,16 L 20,24 L 24,8"
Stroke="{DynamicResource Wd.Accent.Cyan}"
StrokeThickness="2"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
StrokeLineJoin="Round"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock DockPanel.Dock="Top"
Text="WD"
Style="{StaticResource Wd.Text.Caption}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
HorizontalAlignment="Center"
Margin="0,0,0,16"/>
<!-- Divider -->
<Border DockPanel.Dock="Top"
Height="1"
Background="{DynamicResource Wd.Border}"
Margin="14,0,14,12"/>
<!-- Nav: Participants (active) -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
ToolTip="Participants">
<Grid>
<Border Width="48" Height="48"
CornerRadius="8"
Background="{DynamicResource Wd.Accent.CyanMuted}"/>
<Path Data="M 8,17 C 8,13 12,11 14,11 C 16,11 20,13 20,17 M 14,5 C 16,5 17.5,6.5 17.5,8.5 C 17.5,10.5 16,12 14,12 C 12,12 10.5,10.5 10.5,8.5 C 10.5,6.5 12,5 14,5"
Stroke="{DynamicResource Wd.Accent.Cyan}"
StrokeThickness="1.6"
Fill="Transparent"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="28" Height="22"
Stretch="Uniform"/>
</Grid>
</Button>
<!-- Nav: Settings (placeholder — opens panel on right; not toggled in this build) -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
ToolTip="Settings">
<Path Data="M 14,4 L 14,7 M 14,21 L 14,24 M 4,14 L 7,14 M 21,14 L 24,14 M 7.5,7.5 L 9.5,9.5 M 18.5,18.5 L 20.5,20.5 M 7.5,20.5 L 9.5,18.5 M 18.5,9.5 L 20.5,7.5 M 14,11 C 15.7,11 17,12.3 17,14 C 17,15.7 15.7,17 14,17 C 12.3,17 11,15.7 11,14 C 11,12.3 12.3,11 14,11"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.5"
Fill="Transparent"
Width="22" Height="22"
Stretch="Uniform"/>
</Button>
<!-- Engine status indicator at bottom -->
<Border DockPanel.Dock="Bottom"
Width="40" Height="40"
Margin="0,0,0,16"
HorizontalAlignment="Center"
Background="{DynamicResource Wd.Status.LiveBg}"
CornerRadius="20"
ToolTip="{Binding StatusText}">
<Ellipse Width="10" Height="10"
Fill="{DynamicResource Wd.Status.Live}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</DockPanel>
</Border>
<!-- Status footer -->
<Border DockPanel.Dock="Bottom" Background="#191B1F" Padding="10,6">
<TextBlock Text="{Binding StatusText}" FontStyle="Italic" Foreground="#A0A0A0"/>
</Border>
<!-- ════════════════════════════════════════════════════════════════
MAIN CONTENT
════════════════════════════════════════════════════════════════ -->
<DockPanel Grid.Column="1" LastChildFill="True">
<!-- Settings sidebar -->
<Border DockPanel.Dock="Right" Background="#2A2C32" Width="320" Padding="14">
<!-- Alert banner -->
<Border DockPanel.Dock="Top"
Background="{DynamicResource Wd.Accent.CoralBg}"
BorderBrush="{DynamicResource Wd.Accent.Coral}"
BorderThickness="0,0,0,1"
Padding="24,12"
Visibility="{Binding AlertBanner.IsVisible, Converter={StaticResource BoolToVis}}">
<DockPanel>
<Button DockPanel.Dock="Right"
Style="{StaticResource Wd.Button.Ghost}"
Content="Dismiss"
Command="{Binding AlertBanner.DismissCommand}"
Margin="12,0,0,0"/>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="8" Height="8"
Fill="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBlock Text="{Binding AlertBanner.Message}"
Style="{StaticResource Wd.Text.Body}"
FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
</DockPanel>
</Border>
<!-- Header strip -->
<Border DockPanel.Dock="Top"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,0,1"
Padding="32,20">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="TeamsISO"
Style="{StaticResource Wd.Text.Title}"
VerticalAlignment="Center"/>
<Border Style="{StaticResource Wd.Pill}"
Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="by Wild Dragon"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Cyan}"/>
</Border>
</StackPanel>
<Border Grid.Column="1"
Style="{StaticResource Wd.Pill}"
Background="{DynamicResource Wd.Status.LiveBg}"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<Ellipse Width="7" Height="7"
Fill="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding StatusText}"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="8,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</Border>
<!-- Footer -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,1,0,0"
Padding="32,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="WOOGLIN"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1"
Text="{Binding StatusText}"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2"
Text="wilddragon.net · 1.0.0-alpha"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Body -->
<Grid Margin="32,28,32,28">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Section header -->
<StackPanel Grid.Row="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Participants"
Style="{StaticResource Wd.Text.Heading}"
FontSize="18"/>
<Border Style="{StaticResource Wd.Pill}"
Margin="12,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding Participants.Count}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"/>
</Border>
</StackPanel>
<TextBlock Grid.Row="1"
Text="Toggle a participant's ISO to spin up an isolated, normalized NDI output."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,6,0,18"/>
<!-- Participants card -->
<Border Grid.Row="2"
Style="{StaticResource Wd.Card}"
Padding="0">
<DataGrid ItemsSource="{Binding Participants}"
Margin="6,4,6,4">
<DataGrid.Columns>
<DataGridTemplateColumn Header="Display Name" Width="2*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Border Style="{StaticResource Wd.Avatar}">
<TextBlock Text="{Binding DisplayName, Converter={StaticResource Initials}}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Sans}"
FontWeight="SemiBold"
FontSize="11"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding DisplayName}"
Style="{StaticResource Wd.Text.Body}"
FontWeight="Medium"
Margin="14,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Source" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding SourceMachine}"
Style="{StaticResource Wd.Text.Mono}"
VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Output Name" Width="2*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBox Text="{Binding CustomName, UpdateSourceTrigger=PropertyChanged}"
Padding="10,7"
Margin="0,0,12,0"
VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="ISO" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
<Setter Property="Content" Value="Enable"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Secondary}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEnabled}" Value="True">
<Setter Property="Content" Value="● LIVE"/>
<Setter Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Cyan}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsProcessing}" Value="True">
<Setter Property="Content" Value="…"/>
<Setter Property="IsEnabled" Value="False"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Border>
</Grid>
</DockPanel>
<!-- ════════════════════════════════════════════════════════════════
SETTINGS PANEL (right)
════════════════════════════════════════════════════════════════ -->
<Border Grid.Column="2"
Background="{DynamicResource Wd.Surface}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1,0,0,0">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Global Settings" FontSize="16" FontWeight="Bold" Margin="0,0,0,12"/>
<StackPanel Margin="24,28,24,32">
<TextBlock Text="Target Framerate"/>
<TextBlock Text="Settings"
Style="{StaticResource Wd.Text.Heading}"
FontSize="16"
Margin="0,0,0,4"/>
<TextBlock Text="Per-meeting global processing"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,0,22"/>
<!-- Output Format -->
<TextBlock Text="OUTPUT FORMAT" Style="{StaticResource Wd.Text.Caption}"/>
<TextBlock Text="Target Framerate"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableFramerates}"
SelectedItem="{Binding Settings.Framerate}">
<ComboBox.ItemTemplate>
@ -66,7 +361,9 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Target Resolution"/>
<TextBlock Text="Target Resolution"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableResolutions}"
SelectedItem="{Binding Settings.Resolution}">
<ComboBox.ItemTemplate>
@ -76,7 +373,9 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Aspect Mode"/>
<TextBlock Text="Aspect Mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAspectModes}"
SelectedItem="{Binding Settings.Aspect}">
<ComboBox.ItemTemplate>
@ -86,7 +385,9 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Audio Mode"/>
<TextBlock Text="Audio Mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAudioModes}"
SelectedItem="{Binding Settings.Audio}">
<ComboBox.ItemTemplate>
@ -96,64 +397,62 @@
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Apply" Command="{Binding Settings.ApplyCommand}" Margin="0,16,0,0"/>
<!-- NDI Network -->
<TextBlock Text="NDI NETWORK"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,28,0,0"/>
<TextBlock Text="Discovery group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<TextBox Text="{Binding Settings.DiscoveryGroups, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Receive sources from this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
<TextBlock Text="Output group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBox Text="{Binding Settings.OutputGroups, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Broadcast TeamsISO outputs on this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
<Border Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Padding="12,10"
Margin="0,12,0,0">
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
Text="Group changes apply on next launch — running pipelines aren't restarted to avoid orphaning live ISOs."/>
</Border>
<!-- Display -->
<TextBlock Text="DISPLAY"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,28,0,0"/>
<CheckBox Content="Hide my self-preview from participants"
IsChecked="{Binding Settings.HideLocalSelf}"
Margin="0,10,0,0"/>
<Button Style="{StaticResource Wd.Button.Primary}"
Content="Apply Changes"
Command="{Binding Settings.ApplyCommand}"
HorizontalAlignment="Stretch"
Margin="0,28,0,0"
Padding="0,11"/>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Main content: participants list -->
<Grid Margin="14">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="Participants"
FontSize="16" FontWeight="Bold"
Margin="0,0,0,8"/>
<DataGrid Grid.Row="1"
ItemsSource="{Binding Participants}"
AutoGenerateColumns="False"
Background="#2A2C32"
Foreground="#E8E8E8"
RowBackground="#2A2C32"
AlternatingRowBackground="#2E3037"
BorderBrush="#5A5C63"
GridLinesVisibility="Horizontal"
HeadersVisibility="Column"
CanUserAddRows="False" CanUserDeleteRows="False"
RowHeight="34">
<DataGrid.Columns>
<DataGridTextColumn Header="Display Name" Binding="{Binding DisplayName}" Width="2*" IsReadOnly="True"/>
<DataGridTextColumn Header="Source" Binding="{Binding SourceMachine}" Width="*" IsReadOnly="True"/>
<DataGridTextColumn Header="ISO Output Name" Binding="{Binding CustomName, UpdateSourceTrigger=PropertyChanged}" Width="2*"/>
<DataGridTemplateColumn Header="ISO" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Content="{Binding IsEnabled, StringFormat={}{0}}" Command="{Binding ToggleIsoCommand}">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Content" Value="Enable"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEnabled}" Value="True">
<Setter Property="Content" Value="Disable"/>
<Setter Property="Background" Value="#3D6F45"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsProcessing}" Value="True">
<Setter Property="Content" Value="…"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</DockPanel>
</Grid>
</Window>

View file

@ -0,0 +1,544 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<!--
TeamsISO design system — Wild Dragon brand × Microsoft Teams layout.
Brand reference: wilddragon.net
Primary canvas: #0a0a0a
Accent cyan: #97EDF0
Secondary blue: #9AE0FD
Coral (errors): #FB819C
Earth (warnings): #423825
Fonts: Inter (primary), JetBrains Mono
Layout reference: Microsoft Teams desktop
- Left rail (72px) for app icon + primary nav
- Top header inside content area
- Card-based main panel with rounded corners (8-12px)
- Subtle elevation through tone, not heavy shadows
- Avatar circles for participants
Inter is not a Windows system font; we list it first and fall back to
Segoe UI Variable Display (Windows 11) and Segoe UI. JetBrains Mono
falls back to Cascadia Mono.
-->
<!-- ════ Spacing (8px grid) ════ -->
<sys:Double x:Key="Space.XS">4</sys:Double>
<sys:Double x:Key="Space.S">8</sys:Double>
<sys:Double x:Key="Space.M">12</sys:Double>
<sys:Double x:Key="Space.L">16</sys:Double>
<sys:Double x:Key="Space.XL">24</sys:Double>
<sys:Double x:Key="Space.XXL">32</sys:Double>
<!-- ════ Radii (Teams uses 6/8/12) ════ -->
<CornerRadius x:Key="Radius.S">6</CornerRadius>
<CornerRadius x:Key="Radius.M">8</CornerRadius>
<CornerRadius x:Key="Radius.L">12</CornerRadius>
<CornerRadius x:Key="Radius.XL">16</CornerRadius>
<!-- ════ Wild Dragon palette (dark) ════ -->
<SolidColorBrush x:Key="Wd.Canvas" Color="#0A0A0A"/>
<SolidColorBrush x:Key="Wd.Rail" Color="#080808"/>
<SolidColorBrush x:Key="Wd.Surface" Color="#141414"/>
<SolidColorBrush x:Key="Wd.SurfaceElevated" Color="#1C1C1C"/>
<SolidColorBrush x:Key="Wd.SurfaceHover" Color="#242424"/>
<SolidColorBrush x:Key="Wd.SurfaceActive" Color="#2D2D2D"/>
<SolidColorBrush x:Key="Wd.Border" Color="#262626"/>
<SolidColorBrush x:Key="Wd.BorderStrong" Color="#383838"/>
<SolidColorBrush x:Key="Wd.Text.Primary" Color="#F5F5F5"/>
<SolidColorBrush x:Key="Wd.Text.Secondary" Color="#A3A3A3"/>
<SolidColorBrush x:Key="Wd.Text.Tertiary" Color="#6B6B6B"/>
<SolidColorBrush x:Key="Wd.Text.Disabled" Color="#404040"/>
<!-- Accents from wilddragon.net -->
<SolidColorBrush x:Key="Wd.Accent.Cyan" Color="#97EDF0"/>
<SolidColorBrush x:Key="Wd.Accent.CyanHover" Color="#B5F2F4"/>
<SolidColorBrush x:Key="Wd.Accent.CyanMuted" Color="#1B3537"/>
<SolidColorBrush x:Key="Wd.Accent.Blue" Color="#9AE0FD"/>
<SolidColorBrush x:Key="Wd.Accent.Coral" Color="#FB819C"/>
<SolidColorBrush x:Key="Wd.Accent.CoralBg" Color="#3A1922"/>
<SolidColorBrush x:Key="Wd.Status.Live" Color="#4ADE80"/>
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#13261A"/>
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#FBBF24"/>
<SolidColorBrush x:Key="Wd.Status.Error" Color="#FB819C"/>
<!-- ════ Typography ════ -->
<FontFamily x:Key="Wd.Font.Sans">Inter, Segoe UI Variable Display, Segoe UI, sans-serif</FontFamily>
<FontFamily x:Key="Wd.Font.Mono">JetBrains Mono, Cascadia Mono, Consolas, monospace</FontFamily>
<Style x:Key="Wd.Text.Title" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
</Style>
<Style x:Key="Wd.Text.Heading" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
</Style>
<Style x:Key="Wd.Text.Body" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
</Style>
<Style x:Key="Wd.Text.Subtle" TargetType="TextBlock" BasedOn="{StaticResource Wd.Text.Body}">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Secondary}"/>
</Style>
<Style x:Key="Wd.Text.Caption" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Tertiary}"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
</Style>
<Style x:Key="Wd.Text.Mono" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Mono}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Secondary}"/>
</Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource Wd.Text.Body}"/>
<!-- ════ Buttons ════ -->
<!-- Ghost: bordered, transparent fill -->
<Style x:Key="Wd.Button.Ghost" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,8"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource Radius.M}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceActive}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Disabled}"/>
<Setter TargetName="Bd" Property="Opacity" Value="0.6"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Primary: cyan-on-black for the brand action -->
<Style x:Key="Wd.Button.Primary" TargetType="Button" BasedOn="{StaticResource Wd.Button.Ghost}">
<Setter Property="Background" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="Foreground" Value="#0A0A0A"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource Radius.M}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.CyanHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Rail-icon button: square 48x48, used on the Teams-style left rail -->
<Style x:Key="Wd.Button.RailIcon" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Secondary}"/>
<Setter Property="Width" Value="48"/>
<Setter Property="Height" Value="48"/>
<Setter Property="Margin" Value="0,4"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
CornerRadius="{StaticResource Radius.M}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ISO toggle: pill, status-coded -->
<Style x:Key="Wd.Button.IsoToggle" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="MinWidth" Value="84"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="999">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceActive}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ════ TextBox ════ -->
<Style x:Key="Wd.TextBox.Default" TargetType="TextBox">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{StaticResource Wd.Surface}"/>
<Setter Property="CaretBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="SelectionBrush" Value="{StaticResource Wd.Accent.CyanMuted}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,9"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource Radius.M}">
<ScrollViewer x:Name="PART_ContentHost"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
Focusable="False"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="TextBox" BasedOn="{StaticResource Wd.TextBox.Default}"/>
<!-- ════ ComboBox ════ -->
<Style x:Key="Wd.ComboToggle" TargetType="ToggleButton">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="ClickMode" Value="Press"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Path HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,14,0"
Data="M 0,0 L 4,4 L 8,0 Z"
Fill="{StaticResource Wd.Text.Secondary}"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{StaticResource Wd.Surface}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,9"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource Radius.M}"/>
<ToggleButton Style="{StaticResource Wd.ComboToggle}"
IsChecked="{Binding IsDropDownOpen,
RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"/>
<ContentPresenter Margin="{TemplateBinding Padding}"
IsHitTestVisible="False"
VerticalAlignment="Center"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"/>
<Popup IsOpen="{TemplateBinding IsDropDownOpen}"
Placement="Bottom"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="None">
<Border Background="{StaticResource Wd.SurfaceElevated}"
BorderBrush="{StaticResource Wd.BorderStrong}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="280"
Margin="0,4,0,0">
<ScrollViewer><ItemsPresenter/></ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Padding" Value="12,9"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="Transparent"
CornerRadius="{StaticResource Radius.S}"
Margin="4,2"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ════ CheckBox ════ -->
<Style TargetType="CheckBox">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="10,0,0,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal">
<Border x:Name="Box"
Width="18" Height="18"
BorderBrush="{StaticResource Wd.BorderStrong}"
BorderThickness="1"
Background="{StaticResource Wd.Surface}"
CornerRadius="4"
VerticalAlignment="Center">
<Path x:Name="Tick"
Data="M 4,9 L 7.5,12.5 L 14,5"
Stroke="#0A0A0A" StrokeThickness="2"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Visibility="Collapsed"/>
</Border>
<ContentPresenter Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Box" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Tick" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ════ Card ════ -->
<Style x:Key="Wd.Card" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Wd.Surface}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="{StaticResource Radius.L}"/>
<Setter Property="Padding" Value="16"/>
</Style>
<!-- ════ DataGrid (re-skinned, Teams-style) ════ -->
<Style TargetType="DataGrid">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="GridLinesVisibility" Value="None"/>
<Setter Property="HeadersVisibility" Value="Column"/>
<Setter Property="RowHeight" Value="56"/>
<Setter Property="RowBackground" Value="Transparent"/>
<Setter Property="AlternatingRowBackground" Value="Transparent"/>
<Setter Property="HorizontalScrollBarVisibility" Value="Auto"/>
<Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
<Setter Property="CanUserAddRows" Value="False"/>
<Setter Property="CanUserDeleteRows" Value="False"/>
<Setter Property="CanUserResizeRows" Value="False"/>
<Setter Property="SelectionMode" Value="Single"/>
<Setter Property="SelectionUnit" Value="FullRow"/>
<Setter Property="AutoGenerateColumns" Value="False"/>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Tertiary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Setter Property="Padding" Value="14,12"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
</Style>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceElevated}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,0"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ════ ScrollBar (slim) ════ -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="10"/>
<Setter Property="MinWidth" Value="10"/>
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="10"/>
<Setter Property="MinHeight" Value="10"/>
<Setter Property="Width" Value="Auto"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- ════ Avatar (28px circle with initials) ════ -->
<Style x:Key="Wd.Avatar" TargetType="Border">
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="32"/>
<Setter Property="CornerRadius" Value="16"/>
<Setter Property="Background" Value="{StaticResource Wd.Accent.CyanMuted}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<!-- ════ Status pill ════ -->
<Style x:Key="Wd.Pill" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceElevated}"/>
<Setter Property="CornerRadius" Value="999"/>
<Setter Property="Padding" Value="10,4"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
</ResourceDictionary>

View file

@ -4,7 +4,8 @@ using TeamsISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Bindings for the global settings panel: framerate, resolution, aspect, audio.
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
/// NDI groups (discovery + output), and the participant-list hide-Local toggle.
/// </summary>
public sealed class GlobalSettingsViewModel : ObservableObject
{
@ -13,6 +14,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private TargetResolution _resolution;
private AspectMode _aspect;
private AudioMode _audio;
private string _discoveryGroups;
private string _outputGroups;
private bool _hideLocalSelf = true;
public GlobalSettingsViewModel(IIsoController controller)
{
@ -22,6 +26,11 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_resolution = current.Resolution;
_aspect = current.Aspect;
_audio = current.Audio;
var groups = controller.GroupSettings;
_discoveryGroups = groups.DiscoveryGroups ?? string.Empty;
_outputGroups = groups.OutputGroups ?? string.Empty;
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
}
@ -35,11 +44,29 @@ public sealed class GlobalSettingsViewModel : ObservableObject
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
/// <summary>NDI discovery group(s) — comma-separated. Empty = default (Public).</summary>
public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); }
/// <summary>NDI output group(s) — comma-separated. Empty = default (Public).</summary>
public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); }
/// <summary>
/// Hide the user's own self-preview ("(Local)") from the participants list.
/// On by default — operators rarely want to ISO-route their own preview.
/// Read by <see cref="MainViewModel"/> when filtering the list it presents.
/// </summary>
public bool HideLocalSelf { get => _hideLocalSelf; set => SetField(ref _hideLocalSelf, value); }
public AsyncRelayCommand ApplyCommand { get; }
private Task ApplyAsync()
private async Task ApplyAsync()
{
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
return _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
await _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
var groups = new NdiGroupSettings(
DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(),
OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);
}
}

View file

@ -61,8 +61,14 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
{
var seenIds = new HashSet<Guid>();
var hideLocal = Settings.HideLocalSelf;
foreach (var p in incoming)
{
// The new Teams client emits a "(Local)" pseudo-participant for the user's
// own preview — operators rarely want it as a routable ISO. Suppress when
// HideLocalSelf is on (default).
if (hideLocal && IsLocalSelf(p)) continue;
seenIds.Add(p.Id);
if (_byId.TryGetValue(p.Id, out var vm))
{
@ -76,7 +82,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
}
// Remove participants no longer present
// Remove participants no longer present (or now hidden by the filter).
for (var i = Participants.Count - 1; i >= 0; i--)
{
var vm = Participants[i];
@ -88,6 +94,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
}
private static bool IsLocalSelf(Participant p) =>
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
public void Dispose()
{
_participantsSub.Dispose();

View file

@ -37,12 +37,48 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
// ---- Discovery ----
public NdiFindHandle CreateFinder()
public NdiFindHandle CreateFinder(string? groups = null)
{
var native = NdiNative.FindCreateV2(IntPtr.Zero);
if (native == IntPtr.Zero)
throw new InvalidOperationException("NDIlib_find_create_v2 returned null.");
return new NdiPInvokeFindHandle(native);
// Empty/whitespace -> default (Public). Otherwise allocate a UTF-8 buffer
// for the comma-separated group list and pin a settings struct around it.
var trimmed = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim();
if (trimmed is null)
{
var nativeDefault = NdiNative.FindCreateV2(IntPtr.Zero);
if (nativeDefault == IntPtr.Zero)
throw new InvalidOperationException("NDIlib_find_create_v2 returned null.");
return new NdiPInvokeFindHandle(nativeDefault);
}
var groupsUtf8 = Marshal.StringToHGlobalAnsi(trimmed);
try
{
var settings = new NdiNative.FindCreateSettings
{
show_local_sources = true,
p_groups = groupsUtf8,
p_extra_ips = IntPtr.Zero,
};
var settingsPtr = Marshal.AllocHGlobal(Marshal.SizeOf<NdiNative.FindCreateSettings>());
try
{
Marshal.StructureToPtr(settings, settingsPtr, false);
var native = NdiNative.FindCreateV2(settingsPtr);
if (native == IntPtr.Zero)
throw new InvalidOperationException(
$"NDIlib_find_create_v2 returned null for groups='{trimmed}'.");
_logger.LogInformation("NDI finder created with groups: {Groups}", trimmed);
return new NdiPInvokeFindHandle(native);
}
finally
{
Marshal.FreeHGlobal(settingsPtr);
}
}
finally
{
Marshal.FreeHGlobal(groupsUtf8);
}
}
public IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder)
@ -150,26 +186,34 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
// ---- Send ----
public NdiSenderHandle CreateSender(string outputName)
public NdiSenderHandle CreateSender(string outputName, string? groups = null)
{
var trimmedGroups = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim();
var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName);
var groupsUtf8 = trimmedGroups is null
? IntPtr.Zero
: Marshal.StringToHGlobalAnsi(trimmedGroups);
try
{
var settings = new NdiNative.SendCreateSettings
{
p_ndi_name = nameUtf8,
p_groups = IntPtr.Zero,
p_groups = groupsUtf8,
clock_video = true,
clock_audio = false
};
var native = NdiNative.SendCreate(ref settings);
if (native == IntPtr.Zero)
throw new InvalidOperationException($"NDIlib_send_create returned null for output '{outputName}'.");
throw new InvalidOperationException(
$"NDIlib_send_create returned null for output '{outputName}' on groups '{trimmedGroups ?? "<default>"}'.");
if (trimmedGroups is not null)
_logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups);
return new NdiPInvokeSenderHandle(native, outputName);
}
finally
{
Marshal.FreeHGlobal(nameUtf8);
if (groupsUtf8 != IntPtr.Zero) Marshal.FreeHGlobal(groupsUtf8);
}
}

View file

@ -147,11 +147,25 @@ internal static class NdiNative
public struct SendCreateSettings
{
public IntPtr p_ndi_name; // const char*
public IntPtr p_groups; // const char*
public IntPtr p_groups; // const char* (UTF-8, comma-separated; NULL = "Public")
[MarshalAs(UnmanagedType.U1)] public bool clock_video;
[MarshalAs(UnmanagedType.U1)] public bool clock_audio;
}
/// <summary>
/// Mirrors <c>NDIlib_find_create_t</c>. Used to constrain which NDI groups a
/// finder will discover sources from. Passing NULL/<c>IntPtr.Zero</c> for the
/// settings pointer (or a struct with all fields zero) yields default behavior:
/// <c>show_local_sources=true</c>, groups=<c>"Public"</c>, no extra IPs.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct FindCreateSettings
{
[MarshalAs(UnmanagedType.U1)] public bool show_local_sources;
public IntPtr p_groups; // const char* (UTF-8, comma-separated; NULL = "Public")
public IntPtr p_extra_ips; // const char* (UTF-8, comma-separated; NULL = none)
}
[StructLayout(LayoutKind.Sequential)]
public struct VideoFrameV2
{

View file

@ -17,6 +17,9 @@ public interface IIsoController : IAsyncDisposable
/// <summary>Current global processing settings.</summary>
FrameProcessingSettings GlobalSettings { get; }
/// <summary>Current NDI group settings (discovery + output groups).</summary>
NdiGroupSettings GroupSettings { get; }
/// <summary>Starts discovery and supervises the runtime probe. Returns once startup is complete.</summary>
Task StartAsync(CancellationToken cancellationToken);
@ -31,4 +34,10 @@ public interface IIsoController : IAsyncDisposable
/// <summary>Updates global processing settings and persists them. Currently does not restart running pipelines (Phase C wires that).</summary>
Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken);
/// <summary>
/// Updates the NDI group configuration and persists it. Group changes apply on next process
/// restart — rebuilding finder/sender handles mid-flight would orphan running pipelines.
/// </summary>
Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken);
}

View file

@ -35,6 +35,7 @@ public sealed class IsoController : IIsoController
private readonly object _gate = new();
private FrameProcessingSettings _settings;
private NdiGroupSettings _groupSettings;
private CancellationTokenSource? _cts;
private Task? _discoveryTask;
private Task? _eventPumpTask;
@ -42,6 +43,7 @@ public sealed class IsoController : IIsoController
public IObservable<IReadOnlyList<Participant>> Participants => _participants.AsObservable();
public IObservable<EngineAlert> Alerts => _alerts.AsObservable();
public FrameProcessingSettings GlobalSettings { get { lock (_gate) return _settings; } }
public NdiGroupSettings GroupSettings { get { lock (_gate) return _groupSettings; } }
public IsoController(
INdiInterop interop,
@ -63,12 +65,14 @@ public sealed class IsoController : IIsoController
var loaded = configStore.Load();
_settings = loaded.Global;
_groupSettings = loaded.GroupsOrDefault;
_tracker = new ParticipantTracker(_renameWindow, clock ?? (() => DateTimeOffset.UtcNow));
_discoveryChannel = Channel.CreateUnbounded<DiscoveryEvent>();
_discovery = new NdiDiscoveryService(
interop, _discoveryChannel.Writer,
loggerFactory.CreateLogger<NdiDiscoveryService>());
loggerFactory.CreateLogger<NdiDiscoveryService>(),
_groupSettings.DiscoveryGroups);
}
public async Task StartAsync(CancellationToken cancellationToken)
@ -113,7 +117,17 @@ public sealed class IsoController : IIsoController
throw new InvalidOperationException($"Participant {participantId} not currently visible on the network.");
var output = customName ?? DefaultOutputName(participantId);
var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, _settings);
string? outputGroups;
FrameProcessingSettings settingsSnapshot;
lock (_gate)
{
outputGroups = _groupSettings.OutputGroups;
settingsSnapshot = _settings;
}
var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, settingsSnapshot)
{
OutputGroups = outputGroups,
};
var pipeline = _pipelineFactory(config);
lock (_gate) _pipelines[participantId] = pipeline;
@ -139,19 +153,33 @@ public sealed class IsoController : IIsoController
return PersistAssignmentsAsync(cancellationToken);
}
/// <summary>
/// Updates the NDI group configuration. Note: existing finder/sender handles aren't
/// rebuilt — group changes take effect on the next process restart, since rebuilding
/// the live finder mid-flight would orphan in-flight participants. The settings
/// panel surfaces this caveat to the user.
/// </summary>
public Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken)
{
lock (_gate) _groupSettings = groupSettings;
return PersistAssignmentsAsync(cancellationToken);
}
private Task PersistAssignmentsAsync(CancellationToken cancellationToken)
{
try
{
FrameProcessingSettings settings;
NdiGroupSettings groupSettings;
IReadOnlyList<IsoAssignment> assignments;
lock (_gate)
{
settings = _settings;
groupSettings = _groupSettings;
assignments = _pipelines.Keys.Select(id =>
new IsoAssignment(id, IsEnabled: true, CustomOutputName: null)).ToArray();
}
_configStore.Save(new EngineConfig(settings, assignments));
_configStore.Save(new EngineConfig(settings, assignments, groupSettings));
}
catch (Exception ex)
{

View file

@ -20,12 +20,13 @@ public sealed class NdiDiscoveryService
public NdiDiscoveryService(
INdiInterop interop,
ChannelWriter<DiscoveryEvent> writer,
ILogger<NdiDiscoveryService> logger)
ILogger<NdiDiscoveryService> logger,
string? discoveryGroups = null)
{
_interop = interop;
_writer = writer;
_logger = logger;
_finder = interop.CreateFinder();
_finder = interop.CreateFinder(discoveryGroups);
}
/// <summary>

View file

@ -2,8 +2,12 @@ namespace TeamsISO.Engine.Domain;
public sealed record EngineConfig(
FrameProcessingSettings Global,
IReadOnlyList<IsoAssignment> Assignments)
IReadOnlyList<IsoAssignment> Assignments,
NdiGroupSettings? NdiGroups = null)
{
public static readonly EngineConfig Default =
new(FrameProcessingSettings.Default, Array.Empty<IsoAssignment>());
new(FrameProcessingSettings.Default, Array.Empty<IsoAssignment>(), NdiGroupSettings.Default);
/// <summary>Returns <see cref="NdiGroups"/> or <see cref="NdiGroupSettings.Default"/> when null (legacy configs).</summary>
public NdiGroupSettings GroupsOrDefault => NdiGroups ?? NdiGroupSettings.Default;
}

View file

@ -0,0 +1,27 @@
namespace TeamsISO.Engine.Domain;
/// <summary>
/// Network-layer NDI configuration. Lets the operator place TeamsISO inside an NDI
/// "transcoder" topology where the upstream (Teams) outputs are confined to a private
/// group so they don't pollute the production network, while TeamsISO's own normalized
/// outputs broadcast on the standard <c>"Public"</c> group consumed by the switcher.
///
/// Both fields are comma-separated lists of NDI group names. Whitespace around commas
/// is tolerated. <c>null</c> or whitespace means "use the NDI default", which is the
/// implicit <c>"Public"</c> group.
/// </summary>
/// <param name="DiscoveryGroups">
/// Groups the engine's finder subscribes to when enumerating sources. Set this to
/// the private group your Teams machine is configured to broadcast on (e.g.
/// <c>"teamsiso-input"</c>) when you want to hide raw Teams outputs from the rest of
/// the network.
/// </param>
/// <param name="OutputGroups">
/// Groups TeamsISO's own ISO senders broadcast on. Leave at the default for the
/// normal case where downstream switchers receive over the standard
/// <c>"Public"</c> group; override only for split-audience setups.
/// </param>
public sealed record NdiGroupSettings(string? DiscoveryGroups, string? OutputGroups)
{
public static readonly NdiGroupSettings Default = new(null, null);
}

View file

@ -9,7 +9,15 @@ namespace TeamsISO.Engine.Interop;
public interface INdiInterop
{
// ----- Discovery -----
NdiFindHandle CreateFinder();
/// <summary>
/// Creates an NDI finder that observes sources advertised on the given groups.
/// </summary>
/// <param name="groups">
/// Comma-separated list of NDI group names to subscribe to. <c>null</c> or empty
/// uses the NDI default (<c>"Public"</c>). Whitespace around commas is tolerated.
/// </param>
NdiFindHandle CreateFinder(string? groups = null);
/// <summary>Snapshots the currently-known sources visible to the finder.</summary>
IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder);
@ -24,7 +32,17 @@ public interface INdiInterop
RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs);
// ----- Send -----
NdiSenderHandle CreateSender(string outputName);
/// <summary>
/// Creates an NDI sender broadcasting on the given groups.
/// </summary>
/// <param name="outputName">The NDI source name segment (the part inside parens).</param>
/// <param name="groups">
/// Comma-separated list of NDI group names to broadcast on. <c>null</c> or empty
/// uses the NDI default (<c>"Public"</c>).
/// </param>
NdiSenderHandle CreateSender(string outputName, string? groups = null);
void SendFrame(NdiSenderHandle sender, ProcessedFrame frame);
// ----- Runtime probe -----

View file

@ -164,7 +164,8 @@ public sealed class IsoPipeline : IAsyncDisposable
using var receiver = new NdiReceiver(
interop, config.SourceName, rawChannel.Writer, loggerFactory.CreateLogger<NdiReceiver>());
using var sender = new NdiSender(
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>());
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
config.OutputGroups);
var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(),

View file

@ -20,4 +20,11 @@ public sealed record IsoPipelineConfig(
/// <summary>Bounded processed-frame channel capacity.</summary>
public int ProcessedChannelCapacity { get; init; } = 2;
/// <summary>
/// NDI groups (comma-separated) the output sender broadcasts on. <c>null</c> means
/// "use the NDI default" (Public). Set per pipeline so the controller can drive it
/// from the global NdiGroupSettings without changing this record's identity contract.
/// </summary>
public string? OutputGroups { get; init; }
}

View file

@ -20,13 +20,14 @@ public sealed class NdiSender : IDisposable
INdiInterop interop,
string outputName,
ChannelReader<ProcessedFrame> input,
ILogger<NdiSender> logger)
ILogger<NdiSender> logger,
string? outputGroups = null)
{
_interop = interop;
_outputName = outputName;
_input = input;
_logger = logger;
_handle = interop.CreateSender(outputName);
_handle = interop.CreateSender(outputName, outputGroups);
}
public long FramesSent => Interlocked.Read(ref _framesSent);

View file

@ -122,6 +122,54 @@ public class IsoControllerTests : IDisposable
_store.Load().Global.Should().Be(newSettings);
}
[Fact]
public async Task Constructor_PassesDiscoveryGroupsToFinder()
{
// Pre-populate the config store with a discovery-groups setting; the controller
// reads it on construction and feeds it into the finder.
_store.Save(new EngineConfig(
FrameProcessingSettings.Default,
Array.Empty<IsoAssignment>(),
new NdiGroupSettings(DiscoveryGroups: "teamsiso-input", OutputGroups: null)));
await using var controller = NewController();
_interop.LastFinderGroups.Should().Be("teamsiso-input");
controller.GroupSettings.DiscoveryGroups.Should().Be("teamsiso-input");
}
[Fact]
public async Task EnableIsoAsync_PassesOutputGroupsToPipelineConfig()
{
_store.Save(new EngineConfig(
FrameProcessingSettings.Default,
Array.Empty<IsoAssignment>(),
new NdiGroupSettings(DiscoveryGroups: null, OutputGroups: "Public,producers")));
await using var controller = NewController();
await controller.StartAsync(CancellationToken.None);
_interop.Sources.Add("PC1 (Teams - Jane)");
var pid = await WaitForFirstParticipantAsync(controller);
await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None);
_factoryCalls.Should().HaveCount(1);
_factoryCalls[0].OutputGroups.Should().Be("Public,producers");
}
[Fact]
public async Task SetGroupSettingsAsync_PersistsToConfigStore()
{
await using var controller = NewController();
var newGroups = new NdiGroupSettings(DiscoveryGroups: "teamsiso-input", OutputGroups: "Public");
await controller.SetGroupSettingsAsync(newGroups, CancellationToken.None);
controller.GroupSettings.Should().Be(newGroups);
_store.Load().GroupsOrDefault.Should().Be(newGroups);
}
[Fact]
public async Task StartAsync_RuntimeProbeMismatch_RaisesAlert()
{

View file

@ -17,7 +17,16 @@ public sealed class FakeNdiInterop : INdiInterop
public Dictionary<string, int> ReceiverCreatedCount { get; } = new();
public Dictionary<string, int> SenderCreatedCount { get; } = new();
public NdiFindHandle CreateFinder() => new FakeFindHandle();
/// <summary>Last <c>groups</c> string seen by <see cref="CreateFinder"/>; null = default Public.</summary>
public string? LastFinderGroups { get; private set; }
/// <summary>Per-output <c>groups</c> string seen by <see cref="CreateSender"/>; null = default Public.</summary>
public Dictionary<string, string?> SenderGroups { get; } = new();
public NdiFindHandle CreateFinder(string? groups = null)
{
LastFinderGroups = groups;
return new FakeFindHandle();
}
public IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder) => Sources.ToArray();
public NdiReceiverHandle CreateReceiver(string sourceFullName)
@ -35,9 +44,10 @@ public sealed class FakeNdiInterop : INdiInterop
return null; // simulate timeout
}
public NdiSenderHandle CreateSender(string outputName)
public NdiSenderHandle CreateSender(string outputName, string? groups = null)
{
SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1;
SenderGroups[outputName] = groups;
SentFrames.GetOrAdd(outputName, _ => new List<ProcessedFrame>());
return new FakeSenderHandle(outputName);
}