feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork. 1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now. 2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart. 3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'. 4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets. 5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo. 6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition. Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
This commit is contained in:
parent
01ef4250d7
commit
e8f52a3153
12 changed files with 522 additions and 32 deletions
|
|
@ -58,6 +58,15 @@
|
||||||
<!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. -->
|
<!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. -->
|
||||||
<Property Id="ARPNOREPAIR" Value="1" />
|
<Property Id="ARPNOREPAIR" Value="1" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ARP icon — references the same .ico the WPF host uses. WiX requires the
|
||||||
|
icon resource to live next to the wxs OR be reachable at build time;
|
||||||
|
we point at the published copy under src/TeamsISO.App/Assets so the icon
|
||||||
|
embedded in the MSI matches the icon in the running exe.
|
||||||
|
-->
|
||||||
|
<Icon Id="TeamsISOIcon" SourceFile="$(var.AssetsDir)teamsiso.ico" />
|
||||||
|
<Property Id="ARPPRODUCTICON" Value="TeamsISOIcon" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
|
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
|
||||||
environment block. Missing → warn during install, don't block. The
|
environment block. Missing → warn during install, don't block. The
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<PublishDir>$(MSBuildThisFileDirectory)..\publish\TeamsISO\</PublishDir>
|
<PublishDir>$(MSBuildThisFileDirectory)..\publish\TeamsISO\</PublishDir>
|
||||||
|
|
||||||
<!-- Pass MSBuild values into WiX preprocessor. -->
|
<!-- Pass MSBuild values into WiX preprocessor. -->
|
||||||
<DefineConstants>PublishDir=$(PublishDir)</DefineConstants>
|
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\TeamsISO.App\Assets\</DefineConstants>
|
||||||
|
|
||||||
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
|
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
|
||||||
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
|
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
|
||||||
|
|
|
||||||
171
src/TeamsISO.App/AboutWindow.xaml
Normal file
171
src/TeamsISO.App/AboutWindow.xaml
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<Window x:Class="TeamsISO.App.AboutWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="About TeamsISO"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="460" Height="500"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="ClearType">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="0"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="About TeamsISO"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
Margin="20,12,0,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnClose"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<StackPanel Grid.Row="1"
|
||||||
|
Margin="32,16,32,16"
|
||||||
|
VerticalAlignment="Top">
|
||||||
|
<Image Source="/Assets/dragon-mark.png"
|
||||||
|
Width="80" Height="80"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,0,0,16"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
|
|
||||||
|
<TextBlock Text="TeamsISO"
|
||||||
|
Style="{StaticResource Wd.Text.Title}"
|
||||||
|
FontSize="28"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Per-participant NDI ISO Controller for Microsoft Teams"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource Wd.Card}"
|
||||||
|
Margin="0,20,0,0"
|
||||||
|
Padding="16">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,0,16,4"/>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1"
|
||||||
|
x:Name="VersionText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text=".NET"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,0,16,4"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1"
|
||||||
|
x:Name="RuntimeText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="OS"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,0,16,4"/>
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1"
|
||||||
|
x:Name="OsText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Text="NDI runtime"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,0,16,0"/>
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="1"
|
||||||
|
x:Name="NdiText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Border Grid.Row="2"
|
||||||
|
BorderBrush="{DynamicResource Wd.Border}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Padding="20,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Hyperlink x:Name="WebsiteLink"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
TextDecorations="None"
|
||||||
|
Click="OnWebsiteClick">
|
||||||
|
wilddragon.net
|
||||||
|
</Hyperlink>
|
||||||
|
<Run Text=" · © Wild Dragon LLC"/>
|
||||||
|
</TextBlock>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Close"
|
||||||
|
Click="OnClose"
|
||||||
|
MinWidth="80"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
71
src/TeamsISO.App/AboutWindow.xaml.cs
Normal file
71
src/TeamsISO.App/AboutWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Navigation;
|
||||||
|
using TeamsISO.Engine.NdiInterop;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal "About" dialog. Surfaces enough info that a user filing a support ticket
|
||||||
|
/// can paste version + NDI runtime + OS in a single screenshot.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AboutWindow : Window
|
||||||
|
{
|
||||||
|
public AboutWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
PopulateText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateText()
|
||||||
|
{
|
||||||
|
var asm = typeof(App).Assembly;
|
||||||
|
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||||
|
?? asm.GetName().Version?.ToString()
|
||||||
|
?? "unknown";
|
||||||
|
VersionText.Text = info;
|
||||||
|
RuntimeText.Text = $".NET {Environment.Version}";
|
||||||
|
OsText.Text = Environment.OSVersion.ToString();
|
||||||
|
NdiText.Text = TryGetNdiVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static string TryGetNdiVersion()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var interop = new NdiInteropPInvoke(
|
||||||
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<NdiInteropPInvoke>.Instance);
|
||||||
|
return interop.GetRuntimeVersion();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"not initialized ({ex.Message})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open the company site in the default browser. We intentionally use the
|
||||||
|
/// shell's URL handler rather than a tab inside the app — this is a
|
||||||
|
/// "tell me more" link, not a workflow.
|
||||||
|
/// </summary>
|
||||||
|
private void OnWebsiteClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "https://wilddragon.net",
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort; if shell launch fails the click is a no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/TeamsISO.App/Assets/teamsiso.ico
Normal file
BIN
src/TeamsISO.App/Assets/teamsiso.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
|
|
@ -5,6 +5,7 @@
|
||||||
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
|
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
|
||||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
Title="TeamsISO"
|
Title="TeamsISO"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
Height="780" Width="1280"
|
Height="780" Width="1280"
|
||||||
MinHeight="640" MinWidth="1080"
|
MinHeight="640" MinWidth="1080"
|
||||||
Background="{DynamicResource Wd.Canvas}"
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
|
@ -52,14 +53,20 @@
|
||||||
BorderThickness="0,0,1,0">
|
BorderThickness="0,0,1,0">
|
||||||
<DockPanel LastChildFill="False">
|
<DockPanel LastChildFill="False">
|
||||||
|
|
||||||
<!-- Wild Dragon mark — real logo from wilddragon.net -->
|
<!-- Wild Dragon mark — real logo from wilddragon.net. Clickable: opens About. -->
|
||||||
<Image DockPanel.Dock="Top"
|
<Button DockPanel.Dock="Top"
|
||||||
Source="/Assets/dragon-mark.png"
|
Style="{StaticResource Wd.Button.RailIcon}"
|
||||||
Width="40" Height="40"
|
Width="48" Height="56"
|
||||||
Margin="0,18,0,4"
|
Margin="0,12,0,4"
|
||||||
HorizontalAlignment="Center"
|
Click="OnAboutClick"
|
||||||
RenderOptions.BitmapScalingMode="HighQuality"
|
ToolTip="About TeamsISO">
|
||||||
ToolTip="Wild Dragon"/>
|
<StackPanel>
|
||||||
|
<Image Source="/Assets/dragon-mark.png"
|
||||||
|
Width="32" Height="32"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<TextBlock DockPanel.Dock="Top"
|
<TextBlock DockPanel.Dock="Top"
|
||||||
Text="Wild Dragon"
|
Text="Wild Dragon"
|
||||||
|
|
@ -67,7 +74,7 @@
|
||||||
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
FontSize="9"
|
FontSize="9"
|
||||||
Margin="0,0,0,16"/>
|
Margin="0,0,0,12"/>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<Border DockPanel.Dock="Top"
|
<Border DockPanel.Dock="Top"
|
||||||
|
|
@ -349,29 +356,34 @@
|
||||||
<StackPanel VerticalAlignment="Center">
|
<StackPanel VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding SourceMachine}"
|
<TextBlock Text="{Binding SourceMachine}"
|
||||||
Style="{StaticResource Wd.Text.Mono}"/>
|
Style="{StaticResource Wd.Text.Mono}"/>
|
||||||
<TextBlock Text="{Binding IncomingResolution}"
|
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||||
Style="{StaticResource Wd.Text.Mono}"
|
|
||||||
FontSize="10"
|
FontSize="10"
|
||||||
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
Foreground="{DynamicResource Wd.Text.Tertiary}">
|
||||||
|
<Run Text="{Binding IncomingResolution, Mode=OneWay}"/>
|
||||||
|
<Run Text=" · "/>
|
||||||
|
<Run Text="{Binding IncomingFps, Mode=OneWay}"/>
|
||||||
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
|
|
||||||
<DataGridTemplateColumn Header="Live" Width="100">
|
<DataGridTemplateColumn Header="Live" Width="120">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel VerticalAlignment="Center">
|
<StackPanel VerticalAlignment="Center">
|
||||||
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||||
FontSize="11">
|
FontSize="11">
|
||||||
<Run Text="↓ "/>
|
<Run Text="↓ "/>
|
||||||
<Run Text="{Binding FramesIn}"/>
|
<Run Text="{Binding FramesIn, Mode=OneWay}"/>
|
||||||
|
<Run Text=" ↑ "/>
|
||||||
|
<Run Text="{Binding FramesOut, Mode=OneWay}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||||
FontSize="10"
|
FontSize="10"
|
||||||
Foreground="{DynamicResource Wd.Text.Tertiary}">
|
Foreground="{DynamicResource Wd.Accent.Coral}">
|
||||||
<Run Text="↑ "/>
|
<Run Text="drop "/>
|
||||||
<Run Text="{Binding FramesOut}"/>
|
<Run Text="{Binding FramesDropped, Mode=OneWay}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,43 @@ public partial class MainWindow : Window
|
||||||
/// <summary>Custom close button.</summary>
|
/// <summary>Custom close button.</summary>
|
||||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
|
||||||
|
private void OnAboutClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var about = new AboutWindow { Owner = this };
|
||||||
|
about.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// First step toward the Embedded-Teams roadmap (Phase E.1) — launches the MS
|
/// Toggle behavior: if Teams is already running, ask to stop it; otherwise
|
||||||
/// Teams desktop client as a subprocess so the operator doesn't have to switch
|
/// launch via TeamsLauncher's fallback chain. First step toward the
|
||||||
/// apps to start a meeting.
|
/// Embedded-Teams roadmap (Phase E.1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
|
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (TeamsLauncher.IsRunning())
|
||||||
|
{
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
|
||||||
|
"TeamsISO — Stop Teams",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
var asked = TeamsLauncher.StopAll();
|
||||||
|
if (TeamsLauncher.IsRunning())
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
asked == 0
|
||||||
|
? "No Teams windows responded to close."
|
||||||
|
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
|
||||||
|
"TeamsISO — Stop Teams",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TeamsLauncher.TryLaunch(out var error))
|
if (!TeamsLauncher.TryLaunch(out var error))
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,24 @@ namespace TeamsISO.App.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TeamsLauncher
|
public static class TeamsLauncher
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Heuristic process-name candidates we'll consider as "the Teams process" when
|
||||||
|
/// the rail toggle wants to find a running instance. New MSTeams comes first.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] TeamsProcessNames =
|
||||||
|
{
|
||||||
|
"ms-teams", // new MSTeams binary basename
|
||||||
|
"msteams", // alternate basename observed on some installs
|
||||||
|
"Teams", // classic Teams desktop client
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if any process matching the known Teams binary basenames is running.
|
||||||
|
/// Used by the rail to decide whether to show "Launch Teams" vs "Stop Teams".
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsRunning() =>
|
||||||
|
TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Launches Teams. Returns true if a launch was started successfully (the
|
/// Launches Teams. Returns true if a launch was started successfully (the
|
||||||
/// process may take a few seconds to actually appear). False if every
|
/// process may take a few seconds to actually appear). False if every
|
||||||
|
|
@ -69,6 +87,41 @@ public static class TeamsLauncher
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks every running Teams process to close gracefully via WM_CLOSE
|
||||||
|
/// (CloseMainWindow). Returns the count of processes that exited cleanly within
|
||||||
|
/// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed — Teams' own
|
||||||
|
/// "are you sure" prompt may legitimately keep a process alive briefly, and we
|
||||||
|
/// don't want to nuke the user's call mid-transition.
|
||||||
|
/// </summary>
|
||||||
|
public static int StopAll(TimeSpan? gracePeriod = null)
|
||||||
|
{
|
||||||
|
var grace = gracePeriod ?? TimeSpan.FromSeconds(3);
|
||||||
|
var deadline = DateTime.UtcNow + grace;
|
||||||
|
var asked = 0;
|
||||||
|
foreach (var name in TeamsProcessNames)
|
||||||
|
{
|
||||||
|
foreach (var p in Process.GetProcessesByName(name))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (p.HasExited) { p.Dispose(); continue; }
|
||||||
|
if (p.MainWindowHandle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
p.CloseMainWindow();
|
||||||
|
asked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* defensive: process may have died between enumeration and signal */ }
|
||||||
|
finally { p.Dispose(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Best-effort wait so the rail can flip its icon promptly.
|
||||||
|
while (DateTime.UtcNow < deadline && IsRunning())
|
||||||
|
Thread.Sleep(150);
|
||||||
|
return asked;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryStart(string target, bool useShell)
|
private static bool TryStart(string target, bool useShell)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<RootNamespace>TeamsISO.App</RootNamespace>
|
<RootNamespace>TeamsISO.App</RootNamespace>
|
||||||
<AssemblyName>TeamsISO</AssemblyName>
|
<AssemblyName>TeamsISO</AssemblyName>
|
||||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||||
<ApplicationIcon></ApplicationIcon>
|
<ApplicationIcon>Assets\teamsiso.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Resource Include="Assets\dragon-mark.png" />
|
<Resource Include="Assets\dragon-mark.png" />
|
||||||
<Resource Include="Assets\wild-dragon-wordmark.png" />
|
<Resource Include="Assets\wild-dragon-wordmark.png" />
|
||||||
|
<Resource Include="Assets\teamsiso.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
|
|
||||||
private long _framesIn;
|
private long _framesIn;
|
||||||
private long _framesOut;
|
private long _framesOut;
|
||||||
|
private long _framesDropped;
|
||||||
private string _incomingResolution = "—";
|
private string _incomingResolution = "—";
|
||||||
|
private string _incomingFps = "—";
|
||||||
|
|
||||||
/// <summary>Number of frames the receiver has captured so far.</summary>
|
/// <summary>Number of frames the receiver has captured so far.</summary>
|
||||||
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
|
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
|
||||||
|
|
@ -46,17 +48,31 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
/// <summary>Number of frames the sender has emitted so far.</summary>
|
/// <summary>Number of frames the sender has emitted so far.</summary>
|
||||||
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
|
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
|
||||||
|
|
||||||
|
/// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary>
|
||||||
|
public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
|
||||||
|
|
||||||
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
|
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
|
||||||
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
|
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live incoming framerate as "59.94 fps", or em-dash when fewer than 2 frames
|
||||||
|
/// have been observed since the pipeline started. Computed in the engine via a
|
||||||
|
/// 30-frame moving window.
|
||||||
|
/// </summary>
|
||||||
|
public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); }
|
||||||
|
|
||||||
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
|
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
|
||||||
public void UpdateStats(IsoHealthStats stats)
|
public void UpdateStats(IsoHealthStats stats)
|
||||||
{
|
{
|
||||||
FramesIn = stats.FramesIn;
|
FramesIn = stats.FramesIn;
|
||||||
FramesOut = stats.FramesOut;
|
FramesOut = stats.FramesOut;
|
||||||
|
FramesDropped = stats.FramesDropped;
|
||||||
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
|
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
|
||||||
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
|
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
|
||||||
: "—";
|
: "—";
|
||||||
|
IncomingFps = stats.IncomingFps > 0
|
||||||
|
? $"{stats.IncomingFps:0.0} fps"
|
||||||
|
: "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsProcessing
|
public bool IsProcessing
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ using SysConsole = System.Console;
|
||||||
/// teamsiso-console --list-sources # diagnostic: print every raw NDI source string visible
|
/// teamsiso-console --list-sources # diagnostic: print every raw NDI source string visible
|
||||||
/// on the network for ~5s, then exit. Useful for debugging
|
/// on the network for ~5s, then exit. Useful for debugging
|
||||||
/// why expected Teams sources aren't being classified.
|
/// why expected Teams sources aren't being classified.
|
||||||
|
/// teamsiso-console --version # print engine version, NDI runtime version, exit codes,
|
||||||
|
/// then exit 0. Useful for support requests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
|
|
@ -33,6 +35,14 @@ public static class Program
|
||||||
{
|
{
|
||||||
var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase);
|
var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase);
|
||||||
var listSources = args.Contains("--list-sources", StringComparer.OrdinalIgnoreCase);
|
var listSources = args.Contains("--list-sources", StringComparer.OrdinalIgnoreCase);
|
||||||
|
var version = args.Contains("--version", StringComparer.OrdinalIgnoreCase)
|
||||||
|
|| args.Contains("-v", StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (version)
|
||||||
|
{
|
||||||
|
return PrintVersion();
|
||||||
|
}
|
||||||
|
|
||||||
using var loggerFactory = EngineLogging.CreateConsole(LogLevel.Information);
|
using var loggerFactory = EngineLogging.CreateConsole(LogLevel.Information);
|
||||||
var logger = loggerFactory.CreateLogger("TeamsISO.Console");
|
var logger = loggerFactory.CreateLogger("TeamsISO.Console");
|
||||||
|
|
||||||
|
|
@ -136,6 +146,54 @@ public static class Program
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prints version + diagnostic info. Always exits 0; the strings are intended to
|
||||||
|
/// be pasted into a support ticket.
|
||||||
|
/// </summary>
|
||||||
|
private static int PrintVersion()
|
||||||
|
{
|
||||||
|
var asm = typeof(Program).Assembly;
|
||||||
|
var asmVersion = asm.GetName().Version?.ToString() ?? "unknown";
|
||||||
|
var info = asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
|
||||||
|
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||||
|
.FirstOrDefault()?.InformationalVersion ?? asmVersion;
|
||||||
|
|
||||||
|
SysConsole.WriteLine($"TeamsISO Console");
|
||||||
|
SysConsole.WriteLine($" Version: {info}");
|
||||||
|
SysConsole.WriteLine($" Engine: {asmVersion} (TeamsISO.Engine + TeamsISO.Engine.NdiInterop)");
|
||||||
|
SysConsole.WriteLine($" Runtime: .NET {Environment.Version}");
|
||||||
|
SysConsole.WriteLine($" OS: {Environment.OSVersion}");
|
||||||
|
SysConsole.WriteLine();
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var lf = EngineLogging.CreateConsole(LogLevel.Warning);
|
||||||
|
using var interop = new NdiInteropPInvoke(lf.CreateLogger<NdiInteropPInvoke>());
|
||||||
|
SysConsole.WriteLine($" NDI runtime: {interop.GetRuntimeVersion()}");
|
||||||
|
SysConsole.WriteLine($" Expects prefix: {NdiVersion.ExpectedRuntimeVersionPrefix}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SysConsole.WriteLine($" NDI runtime: NOT INITIALIZED ({ex.Message})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SysConsole.WriteLine($" NDI runtime: requires Windows");
|
||||||
|
}
|
||||||
|
|
||||||
|
SysConsole.WriteLine();
|
||||||
|
SysConsole.WriteLine($"Exit codes:");
|
||||||
|
SysConsole.WriteLine($" 0 clean exit");
|
||||||
|
SysConsole.WriteLine($" 1 not running on Windows");
|
||||||
|
SysConsole.WriteLine($" 2 NDI runtime initialization failed (install from https://ndi.video/tools/)");
|
||||||
|
SysConsole.WriteLine();
|
||||||
|
SysConsole.WriteLine($"Wild Dragon LLC · https://wilddragon.net");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Diagnostic mode: enumerates every raw NDI source string visible to the local
|
/// Diagnostic mode: enumerates every raw NDI source string visible to the local
|
||||||
/// NDI finder for ~5 seconds, prints each unique one, then exits. Bypasses the
|
/// NDI finder for ~5 seconds, prints each unique one, then exits. Bypasses the
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
private Task? _supervisorTask;
|
private Task? _supervisorTask;
|
||||||
private int _consecutiveFailures;
|
private int _consecutiveFailures;
|
||||||
|
|
||||||
// Refs to the currently-live receiver and sender, set by the inner loop on each
|
// Refs to the currently-live receiver, sender, and frame processor, set by the
|
||||||
// restart. Reads via Volatile.Read are safe from any thread (UI's stats poll).
|
// inner loop on each restart. Reads via Volatile.Read are safe from any thread
|
||||||
|
// (UI's stats poll).
|
||||||
private NdiReceiver? _liveReceiver;
|
private NdiReceiver? _liveReceiver;
|
||||||
private NdiSender? _liveSender;
|
private NdiSender? _liveSender;
|
||||||
|
private FrameProcessor? _liveProcessor;
|
||||||
|
|
||||||
// Last-frame metadata, snapshotted out of the RawFrame on capture so we don't
|
// Last-frame metadata, snapshotted out of the RawFrame on capture so we don't
|
||||||
// hold a reference to the frame's pixel buffer past its useful life. Two ints
|
// hold a reference to the frame's pixel buffer past its useful life. Two ints
|
||||||
|
|
@ -33,6 +35,15 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
private int _lastHeight;
|
private int _lastHeight;
|
||||||
private DateTimeOffset? _lastReceivedAt;
|
private DateTimeOffset? _lastReceivedAt;
|
||||||
|
|
||||||
|
// Ring buffer of the last 30 incoming-frame timestamps for live fps display.
|
||||||
|
// Updated on the receiver's capture thread (single writer) and read by the UI
|
||||||
|
// poll thread (single reader); we use a lock for the snapshot path because
|
||||||
|
// an array-of-ticks can't be torn-read atomically.
|
||||||
|
private readonly long[] _frameTimes = new long[30];
|
||||||
|
private int _frameTimesHead;
|
||||||
|
private int _frameTimesCount;
|
||||||
|
private readonly object _frameTimesGate = new();
|
||||||
|
|
||||||
public Guid ParticipantId { get; }
|
public Guid ParticipantId { get; }
|
||||||
public IsoState State { get; private set; } = IsoState.Idle;
|
public IsoState State { get; private set; } = IsoState.Idle;
|
||||||
public int ConsecutiveFailures => _consecutiveFailures;
|
public int ConsecutiveFailures => _consecutiveFailures;
|
||||||
|
|
@ -47,6 +58,7 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
{
|
{
|
||||||
var receiver = Volatile.Read(ref _liveReceiver);
|
var receiver = Volatile.Read(ref _liveReceiver);
|
||||||
var sender = Volatile.Read(ref _liveSender);
|
var sender = Volatile.Read(ref _liveSender);
|
||||||
|
var processor = Volatile.Read(ref _liveProcessor);
|
||||||
var w = Volatile.Read(ref _lastWidth);
|
var w = Volatile.Read(ref _lastWidth);
|
||||||
var h = Volatile.Read(ref _lastHeight);
|
var h = Volatile.Read(ref _lastHeight);
|
||||||
var lastAt = _lastReceivedAt;
|
var lastAt = _lastReceivedAt;
|
||||||
|
|
@ -54,17 +66,68 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
if (receiver is null || sender is null)
|
if (receiver is null || sender is null)
|
||||||
return Domain.IsoHealthStats.Empty;
|
return Domain.IsoHealthStats.Empty;
|
||||||
|
|
||||||
|
// FrameProcessor.Stats already aggregates FramesDropped (older frames dropped
|
||||||
|
// by the closest-frame strategy when the input channel had backlog) and
|
||||||
|
// FramesDuplicated (last-frame re-emits when no new frame arrived this tick).
|
||||||
|
var procStats = processor?.Stats;
|
||||||
|
|
||||||
return new Domain.IsoHealthStats(
|
return new Domain.IsoHealthStats(
|
||||||
FramesIn: receiver.FramesCaptured,
|
FramesIn: receiver.FramesCaptured,
|
||||||
FramesOut: sender.FramesSent,
|
FramesOut: sender.FramesSent,
|
||||||
FramesDropped: 0, // FrameProcessor currently doesn't surface drops; wire later
|
FramesDropped: procStats?.FramesDropped ?? 0,
|
||||||
FramesDuplicated: 0, // same — last-frame re-emits aren't counted yet
|
FramesDuplicated: procStats?.FramesDuplicated ?? 0,
|
||||||
LastFrameAt: lastAt,
|
LastFrameAt: lastAt,
|
||||||
IncomingFps: 0, // running rate computation is a follow-up
|
IncomingFps: ComputeFps(),
|
||||||
IncomingWidth: w,
|
IncomingWidth: w,
|
||||||
IncomingHeight: h);
|
IncomingHeight: h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a moving-average incoming framerate from the last N frame timestamps.
|
||||||
|
/// Rate = (count - 1) / (newest - oldest). Returns 0 if fewer than 2 frames are
|
||||||
|
/// recorded or if the window is degenerate (clock skew, all-equal timestamps).
|
||||||
|
/// </summary>
|
||||||
|
private double ComputeFps()
|
||||||
|
{
|
||||||
|
long oldest, newest;
|
||||||
|
int count;
|
||||||
|
lock (_frameTimesGate)
|
||||||
|
{
|
||||||
|
count = _frameTimesCount;
|
||||||
|
if (count < 2) return 0;
|
||||||
|
// Oldest is at the slot AFTER head when buffer is full; otherwise at index 0.
|
||||||
|
var oldestIdx = count < _frameTimes.Length
|
||||||
|
? 0
|
||||||
|
: _frameTimesHead;
|
||||||
|
var newestIdx = (_frameTimesHead - 1 + _frameTimes.Length) % _frameTimes.Length;
|
||||||
|
oldest = _frameTimes[oldestIdx];
|
||||||
|
newest = _frameTimes[newestIdx];
|
||||||
|
}
|
||||||
|
var deltaTicks = newest - oldest;
|
||||||
|
if (deltaTicks <= 0) return 0;
|
||||||
|
var seconds = deltaTicks / (double)TimeSpan.TicksPerSecond;
|
||||||
|
return (count - 1) / seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordFrameTimestamp(long ticks)
|
||||||
|
{
|
||||||
|
lock (_frameTimesGate)
|
||||||
|
{
|
||||||
|
_frameTimes[_frameTimesHead] = ticks;
|
||||||
|
_frameTimesHead = (_frameTimesHead + 1) % _frameTimes.Length;
|
||||||
|
if (_frameTimesCount < _frameTimes.Length) _frameTimesCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetFrameTimestamps()
|
||||||
|
{
|
||||||
|
lock (_frameTimesGate)
|
||||||
|
{
|
||||||
|
_frameTimesHead = 0;
|
||||||
|
_frameTimesCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test ctor. The caller supplies the inner runner directly so failures and lifetimes
|
/// Test ctor. The caller supplies the inner runner directly so failures and lifetimes
|
||||||
/// can be controlled from a unit test.
|
/// can be controlled from a unit test.
|
||||||
|
|
@ -105,15 +168,19 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
{
|
{
|
||||||
_runInner = ct => RunInnerPipelineAsync(
|
_runInner = ct => RunInnerPipelineAsync(
|
||||||
config, interop, scaler, frameClock, loggerFactory, ct,
|
config, interop, scaler, frameClock, loggerFactory, ct,
|
||||||
onLive: (recv, send) =>
|
onLive: (recv, send, proc) =>
|
||||||
{
|
{
|
||||||
Volatile.Write(ref _liveReceiver, recv);
|
Volatile.Write(ref _liveReceiver, recv);
|
||||||
Volatile.Write(ref _liveSender, send);
|
Volatile.Write(ref _liveSender, send);
|
||||||
|
Volatile.Write(ref _liveProcessor, proc);
|
||||||
|
ResetFrameTimestamps(); // fresh window on every supervisor restart
|
||||||
},
|
},
|
||||||
onClear: () =>
|
onClear: () =>
|
||||||
{
|
{
|
||||||
Volatile.Write(ref _liveReceiver, null);
|
Volatile.Write(ref _liveReceiver, null);
|
||||||
Volatile.Write(ref _liveSender, null);
|
Volatile.Write(ref _liveSender, null);
|
||||||
|
Volatile.Write(ref _liveProcessor, null);
|
||||||
|
ResetFrameTimestamps();
|
||||||
},
|
},
|
||||||
onFrame: frame =>
|
onFrame: frame =>
|
||||||
{
|
{
|
||||||
|
|
@ -122,7 +189,9 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
// late stats read can never resurrect a dropped frame's pixel buffer.
|
// late stats read can never resurrect a dropped frame's pixel buffer.
|
||||||
Volatile.Write(ref _lastWidth, frame.Width);
|
Volatile.Write(ref _lastWidth, frame.Width);
|
||||||
Volatile.Write(ref _lastHeight, frame.Height);
|
Volatile.Write(ref _lastHeight, frame.Height);
|
||||||
_lastReceivedAt = DateTimeOffset.UtcNow;
|
var nowTicks = DateTimeOffset.UtcNow.UtcTicks;
|
||||||
|
_lastReceivedAt = new DateTimeOffset(nowTicks, TimeSpan.Zero);
|
||||||
|
RecordFrameTimestamp(nowTicks);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,7 +289,7 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
IFrameClock frameClock,
|
IFrameClock frameClock,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
Action<NdiReceiver, NdiSender>? onLive = null,
|
Action<NdiReceiver, NdiSender, FrameProcessor>? onLive = null,
|
||||||
Action? onClear = null,
|
Action? onClear = null,
|
||||||
Action<RawFrame>? onFrame = null)
|
Action<RawFrame>? onFrame = null)
|
||||||
{
|
{
|
||||||
|
|
@ -249,13 +318,13 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
|
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
|
||||||
config.OutputGroups);
|
config.OutputGroups);
|
||||||
|
|
||||||
onLive?.Invoke(receiver, sender);
|
|
||||||
|
|
||||||
var processor = new FrameProcessor(
|
var processor = new FrameProcessor(
|
||||||
config.Settings, scaler, new SolidFrameRenderer(),
|
config.Settings, scaler, new SolidFrameRenderer(),
|
||||||
frameClock, rawChannel.Reader, processedChannel.Writer,
|
frameClock, rawChannel.Reader, processedChannel.Writer,
|
||||||
config.SlateThreshold, loggerFactory.CreateLogger<FrameProcessor>());
|
config.SlateThreshold, loggerFactory.CreateLogger<FrameProcessor>());
|
||||||
|
|
||||||
|
onLive?.Invoke(receiver, sender, processor);
|
||||||
|
|
||||||
var receiverTask = receiver.RunAsync(ct);
|
var receiverTask = receiver.RunAsync(ct);
|
||||||
var senderTask = sender.RunAsync(ct);
|
var senderTask = sender.RunAsync(ct);
|
||||||
var processorTask = ProcessorLoopAsync(processor, frameClock, ct);
|
var processorTask = ProcessorLoopAsync(processor, frameClock, ct);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue