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. -->
|
||||
<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
|
||||
environment block. Missing → warn during install, don't block. The
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<PublishDir>$(MSBuildThisFileDirectory)..\publish\TeamsISO\</PublishDir>
|
||||
|
||||
<!-- 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). -->
|
||||
<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:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="TeamsISO"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Height="780" Width="1280"
|
||||
MinHeight="640" MinWidth="1080"
|
||||
Background="{DynamicResource Wd.Canvas}"
|
||||
|
|
@ -52,14 +53,20 @@
|
|||
BorderThickness="0,0,1,0">
|
||||
<DockPanel LastChildFill="False">
|
||||
|
||||
<!-- Wild Dragon mark — real logo from wilddragon.net -->
|
||||
<Image DockPanel.Dock="Top"
|
||||
Source="/Assets/dragon-mark.png"
|
||||
Width="40" Height="40"
|
||||
Margin="0,18,0,4"
|
||||
<!-- Wild Dragon mark — real logo from wilddragon.net. Clickable: opens About. -->
|
||||
<Button DockPanel.Dock="Top"
|
||||
Style="{StaticResource Wd.Button.RailIcon}"
|
||||
Width="48" Height="56"
|
||||
Margin="0,12,0,4"
|
||||
Click="OnAboutClick"
|
||||
ToolTip="About TeamsISO">
|
||||
<StackPanel>
|
||||
<Image Source="/Assets/dragon-mark.png"
|
||||
Width="32" Height="32"
|
||||
HorizontalAlignment="Center"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"
|
||||
ToolTip="Wild Dragon"/>
|
||||
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<TextBlock DockPanel.Dock="Top"
|
||||
Text="Wild Dragon"
|
||||
|
|
@ -67,7 +74,7 @@
|
|||
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="9"
|
||||
Margin="0,0,0,16"/>
|
||||
Margin="0,0,0,12"/>
|
||||
|
||||
<!-- Divider -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
|
|
@ -349,29 +356,34 @@
|
|||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding SourceMachine}"
|
||||
Style="{StaticResource Wd.Text.Mono}"/>
|
||||
<TextBlock Text="{Binding IncomingResolution}"
|
||||
Style="{StaticResource Wd.Text.Mono}"
|
||||
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||
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>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="Live" Width="100">
|
||||
<DataGridTemplateColumn Header="Live" Width="120">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||
FontSize="11">
|
||||
<Run Text="↓ "/>
|
||||
<Run Text="{Binding FramesIn}"/>
|
||||
<Run Text="{Binding FramesIn, Mode=OneWay}"/>
|
||||
<Run Text=" ↑ "/>
|
||||
<Run Text="{Binding FramesOut, Mode=OneWay}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}">
|
||||
<Run Text="↑ "/>
|
||||
<Run Text="{Binding FramesOut}"/>
|
||||
Foreground="{DynamicResource Wd.Accent.Coral}">
|
||||
<Run Text="drop "/>
|
||||
<Run Text="{Binding FramesDropped, Mode=OneWay}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
|
|
|||
|
|
@ -29,13 +29,43 @@ public partial class MainWindow : Window
|
|||
/// <summary>Custom close button.</summary>
|
||||
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>
|
||||
/// First step toward the Embedded-Teams roadmap (Phase E.1) — launches the MS
|
||||
/// Teams desktop client as a subprocess so the operator doesn't have to switch
|
||||
/// apps to start a meeting.
|
||||
/// Toggle behavior: if Teams is already running, ask to stop it; otherwise
|
||||
/// launch via TeamsLauncher's fallback chain. First step toward the
|
||||
/// Embedded-Teams roadmap (Phase E.1).
|
||||
/// </summary>
|
||||
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))
|
||||
{
|
||||
MessageBox.Show(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,24 @@ namespace TeamsISO.App.Services;
|
|||
/// </summary>
|
||||
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>
|
||||
/// Launches Teams. Returns true if a launch was started successfully (the
|
||||
/// process may take a few seconds to actually appear). False if every
|
||||
|
|
@ -69,6 +87,41 @@ public static class TeamsLauncher
|
|||
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)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<RootNamespace>TeamsISO.App</RootNamespace>
|
||||
<AssemblyName>TeamsISO</AssemblyName>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ApplicationIcon></ApplicationIcon>
|
||||
<ApplicationIcon>Assets\teamsiso.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
<ItemGroup>
|
||||
<Resource Include="Assets\dragon-mark.png" />
|
||||
<Resource Include="Assets\wild-dragon-wordmark.png" />
|
||||
<Resource Include="Assets\teamsiso.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
|
||||
private long _framesIn;
|
||||
private long _framesOut;
|
||||
private long _framesDropped;
|
||||
private string _incomingResolution = "—";
|
||||
private string _incomingFps = "—";
|
||||
|
||||
/// <summary>Number of frames the receiver has captured so far.</summary>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
public void UpdateStats(IsoHealthStats stats)
|
||||
{
|
||||
FramesIn = stats.FramesIn;
|
||||
FramesOut = stats.FramesOut;
|
||||
FramesDropped = stats.FramesDropped;
|
||||
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
|
||||
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
|
||||
: "—";
|
||||
IncomingFps = stats.IncomingFps > 0
|
||||
? $"{stats.IncomingFps:0.0} fps"
|
||||
: "—";
|
||||
}
|
||||
|
||||
public bool IsProcessing
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ using SysConsole = System.Console;
|
|||
/// teamsiso-console --list-sources # diagnostic: print every raw NDI source string visible
|
||||
/// on the network for ~5s, then exit. Useful for debugging
|
||||
/// 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>
|
||||
public static class Program
|
||||
{
|
||||
|
|
@ -33,6 +35,14 @@ public static class Program
|
|||
{
|
||||
var enableAll = args.Contains("--enable-all", 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);
|
||||
var logger = loggerFactory.CreateLogger("TeamsISO.Console");
|
||||
|
||||
|
|
@ -136,6 +146,54 @@ public static class Program
|
|||
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>
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
private Task? _supervisorTask;
|
||||
private int _consecutiveFailures;
|
||||
|
||||
// Refs to the currently-live receiver and sender, set by the inner loop on each
|
||||
// restart. Reads via Volatile.Read are safe from any thread (UI's stats poll).
|
||||
// Refs to the currently-live receiver, sender, and frame processor, set by the
|
||||
// inner loop on each restart. Reads via Volatile.Read are safe from any thread
|
||||
// (UI's stats poll).
|
||||
private NdiReceiver? _liveReceiver;
|
||||
private NdiSender? _liveSender;
|
||||
private FrameProcessor? _liveProcessor;
|
||||
|
||||
// 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
|
||||
|
|
@ -33,6 +35,15 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
private int _lastHeight;
|
||||
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 IsoState State { get; private set; } = IsoState.Idle;
|
||||
public int ConsecutiveFailures => _consecutiveFailures;
|
||||
|
|
@ -47,6 +58,7 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
{
|
||||
var receiver = Volatile.Read(ref _liveReceiver);
|
||||
var sender = Volatile.Read(ref _liveSender);
|
||||
var processor = Volatile.Read(ref _liveProcessor);
|
||||
var w = Volatile.Read(ref _lastWidth);
|
||||
var h = Volatile.Read(ref _lastHeight);
|
||||
var lastAt = _lastReceivedAt;
|
||||
|
|
@ -54,17 +66,68 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
if (receiver is null || sender is null)
|
||||
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(
|
||||
FramesIn: receiver.FramesCaptured,
|
||||
FramesOut: sender.FramesSent,
|
||||
FramesDropped: 0, // FrameProcessor currently doesn't surface drops; wire later
|
||||
FramesDuplicated: 0, // same — last-frame re-emits aren't counted yet
|
||||
FramesDropped: procStats?.FramesDropped ?? 0,
|
||||
FramesDuplicated: procStats?.FramesDuplicated ?? 0,
|
||||
LastFrameAt: lastAt,
|
||||
IncomingFps: 0, // running rate computation is a follow-up
|
||||
IncomingFps: ComputeFps(),
|
||||
IncomingWidth: w,
|
||||
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>
|
||||
/// Test ctor. The caller supplies the inner runner directly so failures and lifetimes
|
||||
/// can be controlled from a unit test.
|
||||
|
|
@ -105,15 +168,19 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
{
|
||||
_runInner = ct => RunInnerPipelineAsync(
|
||||
config, interop, scaler, frameClock, loggerFactory, ct,
|
||||
onLive: (recv, send) =>
|
||||
onLive: (recv, send, proc) =>
|
||||
{
|
||||
Volatile.Write(ref _liveReceiver, recv);
|
||||
Volatile.Write(ref _liveSender, send);
|
||||
Volatile.Write(ref _liveProcessor, proc);
|
||||
ResetFrameTimestamps(); // fresh window on every supervisor restart
|
||||
},
|
||||
onClear: () =>
|
||||
{
|
||||
Volatile.Write(ref _liveReceiver, null);
|
||||
Volatile.Write(ref _liveSender, null);
|
||||
Volatile.Write(ref _liveProcessor, null);
|
||||
ResetFrameTimestamps();
|
||||
},
|
||||
onFrame: frame =>
|
||||
{
|
||||
|
|
@ -122,7 +189,9 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
// late stats read can never resurrect a dropped frame's pixel buffer.
|
||||
Volatile.Write(ref _lastWidth, frame.Width);
|
||||
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,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken ct,
|
||||
Action<NdiReceiver, NdiSender>? onLive = null,
|
||||
Action<NdiReceiver, NdiSender, FrameProcessor>? onLive = null,
|
||||
Action? onClear = null,
|
||||
Action<RawFrame>? onFrame = null)
|
||||
{
|
||||
|
|
@ -249,13 +318,13 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
|
||||
config.OutputGroups);
|
||||
|
||||
onLive?.Invoke(receiver, sender);
|
||||
|
||||
var processor = new FrameProcessor(
|
||||
config.Settings, scaler, new SolidFrameRenderer(),
|
||||
frameClock, rawChannel.Reader, processedChannel.Writer,
|
||||
config.SlateThreshold, loggerFactory.CreateLogger<FrameProcessor>());
|
||||
|
||||
onLive?.Invoke(receiver, sender, processor);
|
||||
|
||||
var receiverTask = receiver.RunAsync(ct);
|
||||
var senderTask = sender.RunAsync(ct);
|
||||
var processorTask = ProcessorLoopAsync(processor, frameClock, ct);
|
||||
|
|
|
|||
Loading…
Reference in a new issue