Compare commits
No commits in common. "6cac486fbe24af3d0429a4f6cf3893bed29cf3a8" and "fa8d2a8fadf0236d5577b7b1eea4e6b7f6b7c721" have entirely different histories.
6cac486fbe
...
fa8d2a8fad
20 changed files with 130 additions and 1312 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 -----
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue