Compare commits

..

No commits in common. "6cac486fbe24af3d0429a4f6cf3893bed29cf3a8" and "fa8d2a8fadf0236d5577b7b1eea4e6b7f6b7c721" have entirely different histories.

20 changed files with 130 additions and 1312 deletions

View file

@ -1,11 +1,5 @@
<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>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Themes/WildDragonTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Resources/>
</Application>

View file

@ -1,7 +1,5 @@
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;
@ -16,63 +14,15 @@ 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);
@ -152,11 +102,6 @@ public partial class App : Application
{
// Best-effort shutdown
}
finally
{
try { _singleInstanceMutex?.ReleaseMutex(); } catch { /* not owned */ }
_singleInstanceMutex?.Dispose();
}
base.OnExit(e);
}
}

View file

@ -1,30 +0,0 @@
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,355 +3,60 @@
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="780" Width="1280"
MinHeight="640" MinWidth="1080"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
Title="TeamsISO" Height="700" Width="1100"
Background="#202225" Foreground="#E8E8E8">
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:InitialsConverter x:Key="Initials"/>
<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>
</Window.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="72"/> <!-- Left rail -->
<ColumnDefinition Width="*"/> <!-- Main content -->
<ColumnDefinition Width="380"/> <!-- Settings panel -->
</Grid.ColumnDefinitions>
<DockPanel>
<!-- ════════════════════════════════════════════════════════════════
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>
<!-- 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"/>
</DockPanel>
</Border>
<!-- ════════════════════════════════════════════════════════════════
MAIN CONTENT
════════════════════════════════════════════════════════════════ -->
<DockPanel Grid.Column="1" LastChildFill="True">
<!-- Status footer -->
<Border DockPanel.Dock="Bottom" Background="#191B1F" Padding="10,6">
<TextBlock Text="{Binding StatusText}" FontStyle="Italic" Foreground="#A0A0A0"/>
</Border>
<!-- 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">
<!-- Settings sidebar -->
<Border DockPanel.Dock="Right" Background="#2A2C32" Width="320" Padding="14">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="24,28,24,32">
<StackPanel>
<TextBlock Text="Global Settings" FontSize="16" FontWeight="Bold" Margin="0,0,0,12"/>
<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"/>
<TextBlock Text="Target Framerate"/>
<ComboBox ItemsSource="{Binding Settings.AvailableFramerates}"
SelectedItem="{Binding Settings.Framerate}">
<ComboBox.ItemTemplate>
@ -361,9 +66,7 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Target Resolution"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBlock Text="Target Resolution"/>
<ComboBox ItemsSource="{Binding Settings.AvailableResolutions}"
SelectedItem="{Binding Settings.Resolution}">
<ComboBox.ItemTemplate>
@ -373,9 +76,7 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Aspect Mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBlock Text="Aspect Mode"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAspectModes}"
SelectedItem="{Binding Settings.Aspect}">
<ComboBox.ItemTemplate>
@ -385,9 +86,7 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Audio Mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBlock Text="Audio Mode"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAudioModes}"
SelectedItem="{Binding Settings.Audio}">
<ComboBox.ItemTemplate>
@ -397,62 +96,64 @@
</ComboBox.ItemTemplate>
</ComboBox>
<!-- 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"/>
<Button Content="Apply" Command="{Binding Settings.ApplyCommand}" Margin="0,16,0,0"/>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
<!-- 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>
</Window>

View file

@ -1,544 +0,0 @@
<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,8 +4,7 @@ using TeamsISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
/// NDI groups (discovery + output), and the participant-list hide-Local toggle.
/// Bindings for the global settings panel: framerate, resolution, aspect, audio.
/// </summary>
public sealed class GlobalSettingsViewModel : ObservableObject
{
@ -14,9 +13,6 @@ 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)
{
@ -26,11 +22,6 @@ 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);
}
@ -44,29 +35,11 @@ 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 async Task ApplyAsync()
private Task ApplyAsync()
{
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
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);
return _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
}
}

View file

@ -61,14 +61,8 @@ 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))
{
@ -82,7 +76,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
}
// Remove participants no longer present (or now hidden by the filter).
// Remove participants no longer present
for (var i = Participants.Count - 1; i >= 0; i--)
{
var vm = Participants[i];
@ -94,9 +88,6 @@ 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,48 +37,12 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
// ---- Discovery ----
public NdiFindHandle CreateFinder(string? groups = null)
public NdiFindHandle CreateFinder()
{
// 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);
}
var native = NdiNative.FindCreateV2(IntPtr.Zero);
if (native == IntPtr.Zero)
throw new InvalidOperationException("NDIlib_find_create_v2 returned null.");
return new NdiPInvokeFindHandle(native);
}
public IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder)
@ -186,34 +150,26 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
// ---- Send ----
public NdiSenderHandle CreateSender(string outputName, string? groups = null)
public NdiSenderHandle CreateSender(string outputName)
{
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 = groupsUtf8,
p_groups = IntPtr.Zero,
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}' on groups '{trimmedGroups ?? "<default>"}'.");
if (trimmedGroups is not null)
_logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups);
throw new InvalidOperationException($"NDIlib_send_create returned null for output '{outputName}'.");
return new NdiPInvokeSenderHandle(native, outputName);
}
finally
{
Marshal.FreeHGlobal(nameUtf8);
if (groupsUtf8 != IntPtr.Zero) Marshal.FreeHGlobal(groupsUtf8);
}
}

View file

@ -147,25 +147,11 @@ internal static class NdiNative
public struct SendCreateSettings
{
public IntPtr p_ndi_name; // const char*
public IntPtr p_groups; // const char* (UTF-8, comma-separated; NULL = "Public")
public IntPtr p_groups; // const char*
[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,9 +17,6 @@ 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);
@ -34,10 +31,4 @@ 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,7 +35,6 @@ 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;
@ -43,7 +42,6 @@ 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,
@ -65,14 +63,12 @@ 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>(),
_groupSettings.DiscoveryGroups);
loggerFactory.CreateLogger<NdiDiscoveryService>());
}
public async Task StartAsync(CancellationToken cancellationToken)
@ -117,17 +113,7 @@ public sealed class IsoController : IIsoController
throw new InvalidOperationException($"Participant {participantId} not currently visible on the network.");
var output = customName ?? DefaultOutputName(participantId);
string? outputGroups;
FrameProcessingSettings settingsSnapshot;
lock (_gate)
{
outputGroups = _groupSettings.OutputGroups;
settingsSnapshot = _settings;
}
var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, settingsSnapshot)
{
OutputGroups = outputGroups,
};
var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, _settings);
var pipeline = _pipelineFactory(config);
lock (_gate) _pipelines[participantId] = pipeline;
@ -153,33 +139,19 @@ 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, groupSettings));
_configStore.Save(new EngineConfig(settings, assignments));
}
catch (Exception ex)
{

View file

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

View file

@ -2,12 +2,8 @@ namespace TeamsISO.Engine.Domain;
public sealed record EngineConfig(
FrameProcessingSettings Global,
IReadOnlyList<IsoAssignment> Assignments,
NdiGroupSettings? NdiGroups = null)
IReadOnlyList<IsoAssignment> Assignments)
{
public static readonly EngineConfig Default =
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;
new(FrameProcessingSettings.Default, Array.Empty<IsoAssignment>());
}

View file

@ -1,27 +0,0 @@
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,15 +9,7 @@ namespace TeamsISO.Engine.Interop;
public interface INdiInterop
{
// ----- Discovery -----
/// <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);
NdiFindHandle CreateFinder();
/// <summary>Snapshots the currently-known sources visible to the finder.</summary>
IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder);
@ -32,17 +24,7 @@ public interface INdiInterop
RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs);
// ----- Send -----
/// <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);
NdiSenderHandle CreateSender(string outputName);
void SendFrame(NdiSenderHandle sender, ProcessedFrame frame);
// ----- Runtime probe -----

View file

@ -164,8 +164,7 @@ 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>(),
config.OutputGroups);
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>());
var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(),

View file

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

View file

@ -122,54 +122,6 @@ 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,16 +17,7 @@ public sealed class FakeNdiInterop : INdiInterop
public Dictionary<string, int> ReceiverCreatedCount { get; } = new();
public Dictionary<string, int> SenderCreatedCount { get; } = new();
/// <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 NdiFindHandle CreateFinder() => new FakeFindHandle();
public IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder) => Sources.ToArray();
public NdiReceiverHandle CreateReceiver(string sourceFullName)
@ -44,10 +35,9 @@ public sealed class FakeNdiInterop : INdiInterop
return null; // simulate timeout
}
public NdiSenderHandle CreateSender(string outputName, string? groups = null)
public NdiSenderHandle CreateSender(string outputName)
{
SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1;
SenderGroups[outputName] = groups;
SentFrames.GetOrAdd(outputName, _ => new List<ProcessedFrame>());
return new FakeSenderHandle(outputName);
}