teamsiso/src/TeamsISO.App/MainWindow.xaml

1029 lines
64 KiB
Text
Raw Normal View History

<Window x:Class="TeamsISO.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
mc:Ignorable="d"
Title="TeamsISO"
Width="1280" Height="780"
MinWidth="980" MinHeight="640"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource Wd.Canvas}"
Foreground="{DynamicResource Wd.Text.Primary}"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
TextOptions.TextRenderingMode="ClearType"
TextOptions.TextFormattingMode="Display"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
<!--
v2 SHELL — "Studio Terminal"
(Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md)
Default Windows title bar (no chromeless WindowChrome). The 32px header
below it carries the brand mark, wordmark, and three icon buttons:
⌘K (command palette), theme toggle, settings drawer. Below that, a
single transport strip carries the operator's at-a-glance status.
The participants area is the canvas — no rail, no permanent side
panel, no footer. The meeting bar at the bottom renders ONLY when
Teams is in a call.
-->
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"
TrueValue="Visible"
FalseValue="Collapsed"/>
<conv:BoolToVisibilityConverter x:Key="BoolToVisInverse"
TrueValue="Collapsed"
FalseValue="Visible"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:LevelThresholdConverter x:Key="LevelGate"/>
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
</Window.Resources>
<Window.InputBindings>
<KeyBinding Key="F1" Command="{Binding ShowHelpCommand}"/>
<KeyBinding Key="S" Modifiers="Ctrl+Shift" Command="{Binding StopAllIsosCommand}"/>
<KeyBinding Key="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/>
<KeyBinding Key="T" Modifiers="Ctrl" Command="{Binding ToggleThemeCommand}"/>
<!-- Ctrl+K (and Ctrl+P alias) — open the v2 command palette. The
KeyBinding fires on the window; the handler is wired via the
palette button's Click in the header. -->
<KeyBinding Key="K" Modifiers="Ctrl" Command="{Binding OpenCommandPaletteCommand}"/>
<KeyBinding Key="P" Modifiers="Ctrl" Command="{Binding OpenCommandPaletteCommand}"/>
<KeyBinding Key="NumPad1" Command="{Binding ToggleByIndexCommand}" CommandParameter="1"/>
<KeyBinding Key="NumPad2" Command="{Binding ToggleByIndexCommand}" CommandParameter="2"/>
<KeyBinding Key="NumPad3" Command="{Binding ToggleByIndexCommand}" CommandParameter="3"/>
<KeyBinding Key="NumPad4" Command="{Binding ToggleByIndexCommand}" CommandParameter="4"/>
<KeyBinding Key="NumPad5" Command="{Binding ToggleByIndexCommand}" CommandParameter="5"/>
<KeyBinding Key="NumPad6" Command="{Binding ToggleByIndexCommand}" CommandParameter="6"/>
<KeyBinding Key="NumPad7" Command="{Binding ToggleByIndexCommand}" CommandParameter="7"/>
<KeyBinding Key="NumPad8" Command="{Binding ToggleByIndexCommand}" CommandParameter="8"/>
<KeyBinding Key="NumPad9" Command="{Binding ToggleByIndexCommand}" CommandParameter="9"/>
<KeyBinding Key="D1" Command="{Binding ToggleByIndexCommand}" CommandParameter="1"/>
<KeyBinding Key="D2" Command="{Binding ToggleByIndexCommand}" CommandParameter="2"/>
<KeyBinding Key="D3" Command="{Binding ToggleByIndexCommand}" CommandParameter="3"/>
<KeyBinding Key="D4" Command="{Binding ToggleByIndexCommand}" CommandParameter="4"/>
<KeyBinding Key="D5" Command="{Binding ToggleByIndexCommand}" CommandParameter="5"/>
<KeyBinding Key="D6" Command="{Binding ToggleByIndexCommand}" CommandParameter="6"/>
<KeyBinding Key="D7" Command="{Binding ToggleByIndexCommand}" CommandParameter="7"/>
<KeyBinding Key="D8" Command="{Binding ToggleByIndexCommand}" CommandParameter="8"/>
<KeyBinding Key="D9" Command="{Binding ToggleByIndexCommand}" CommandParameter="9"/>
</Window.InputBindings>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="32"/>
<RowDefinition Height="40"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ═════════════════════ HEADER (32px) ═════════════════════ -->
<Border Grid.Row="0"
Background="{DynamicResource Wd.Canvas}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Brand: Wild Dragon mark + wordmark. Click on the mark opens About.
Mark is small (20px) and sits as a quality cue — not as
marketing chrome. -->
<StackPanel Grid.Column="0"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="12,0,0,0">
<Button Click="OnAboutClick"
Padding="2"
Background="Transparent"
BorderThickness="0"
Cursor="Hand"
ToolTip="About TeamsISO">
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.
2026-05-08 13:50:19 -04:00
<Image Source="/Assets/dragon-mark.png"
Width="20" Height="20"
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.
2026-05-08 13:50:19 -04:00
RenderOptions.BitmapScalingMode="HighQuality"/>
</Button>
<TextBlock Text="TeamsISO"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="13"
FontWeight="Medium"
Foreground="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
Margin="8,0,0,0"/>
</StackPanel>
<!-- Right cluster: three icon buttons. ⌘K opens the command
palette (Ctrl+K shortcut). The theme button cycles
dark ↔ light (Ctrl+T). The gear opens the settings
drawer. That's the entire chrome. -->
<StackPanel Grid.Column="2"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,10,0">
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnCommandPaletteClick"
Padding="8,4"
Margin="0,0,2,0"
ToolTip="Command palette (Ctrl+K)"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
Content="⌘K"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleThemeCommand}"
Padding="6,4"
Margin="0,0,2,0"
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
ToolTip="Theme (System / Dark / Light)">
<Path Data="M 8,2 C 8,2 4,4 4,8 C 4,12 8,14 8,14 C 5,14 2,11 2,8 C 2,5 5,2 8,2 Z"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="{DynamicResource Wd.Text.Secondary}"
Width="14" Height="14"
Stretch="None"/>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnSettingsToggleClick"
Padding="6,4"
ToolTip="Settings">
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="14"
Stretch="None"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ═════════════════════ TRANSPORT STRIP (40px) ═════════════════════ -->
<Border Grid.Row="1"
Background="{DynamicResource Wd.Canvas}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,0,1">
<Grid Margin="20,0,20,0" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Orientation="Horizontal"
VerticalAlignment="Center">
<!-- Session timer — green dot + elapsed when at least one ISO is live -->
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,20,0"
Visibility="{Binding IsSessionActive, Converter={StaticResource BoolToVis}}">
<Ellipse Width="7" Height="7"
Fill="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding SessionElapsed}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
FontWeight="Medium"
Foreground="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
Margin="8,0,0,0"/>
</StackPanel>
<!-- Participant counts — labels in tracked smallcaps, values in mono.
The space between value and next label uses a middle dot. -->
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,20,0">
<TextBlock Text="PART"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Text="{Binding ParticipantCount, Mode=OneWay, FallbackValue=0}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"/>
<TextBlock Text=" · "
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Disabled}"
VerticalAlignment="Center"/>
<TextBlock Text="LIVE"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Text="{Binding LiveCount, Mode=OneWay, FallbackValue=0}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Accent.CyanText}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Teams meeting pill — only present in-call -->
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,20,0"
Visibility="{Binding IsTeamsInCall, Converter={StaticResource BoolToVis}}">
<TextBlock Text="CALL"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource Wd.Accent.CyanText}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Text="{Binding TeamsMeetingState}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"
MaxWidth="240"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</StackPanel>
<!-- Control surface — only present when listening, right-aligned -->
<StackPanel Grid.Column="2"
Orientation="Horizontal"
VerticalAlignment="Center"
Visibility="{Binding IsControlSurfaceRunning, Converter={StaticResource BoolToVis}}">
<TextBlock Text="CTRL"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Text="{Binding ControlSurfaceText}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.CyanText}"
VerticalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
<!-- ═════════════════════ BODY ═════════════════════ -->
<Grid Grid.Row="2">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Alert banner — appears when AlertBanner.IsVisible flips on -->
<Border Grid.Row="0"
Background="{DynamicResource Wd.Accent.CoralBg}"
BorderBrush="{DynamicResource Wd.Accent.Coral}"
BorderThickness="0,0,0,1"
Padding="24,10"
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="7" Height="7"
Fill="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,10,0"/>
<TextBlock Text="{Binding AlertBanner.Message}"
Style="{StaticResource Wd.Text.Body}"
FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
</DockPanel>
</Border>
<!-- Update banner — Forgejo release available -->
<Border Grid.Row="1"
Margin="20,12,20,0"
Padding="14,10"
Background="{DynamicResource Wd.Accent.CyanMuted}"
BorderBrush="{DynamicResource Wd.Accent.Cyan}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Visibility="{Binding UpdateBanner.IsVisible, Converter={StaticResource BoolToVis}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Ellipse Grid.Column="0"
Width="7" Height="7"
Fill="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBlock Grid.Column="1"
Text="{Binding UpdateBanner.Message}"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Ghost}"
Content="Get update"
Command="{Binding UpdateBanner.OpenReleasePageCommand}"
Padding="14,4"
Margin="0,0,8,0"/>
<Button Grid.Column="3"
Style="{StaticResource Wd.Button.Ghost}"
Content="Dismiss"
Command="{Binding UpdateBanner.DismissCommand}"
Padding="14,4"/>
</Grid>
</Border>
<!-- Action toolbar — primary verbs above the participants table -->
<Border Grid.Row="2"
Padding="20,14,20,12">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource Wd.Button.Primary}"
Command="{Binding EnableAllOnlineCommand}"
Content="Enable all"
Padding="14,7"
Margin="0,0,8,0"
ToolTip="Enable ISO routing for every online participant"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding RefreshDiscoveryCommand}"
Content="Refresh"
Padding="14,7"
Margin="0,0,8,0"
ToolTip="Refresh NDI discovery (Ctrl+R)"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnPresetsClick"
Content="Presets"
Padding="14,7"
Margin="0,0,8,0"
ToolTip="Open operator presets"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding StopAllIsosCommand}"
Content="Stop all"
Padding="14,7"
ToolTip="Disable every running ISO (Ctrl+Shift+S)"/>
<!-- Teams launch / hide — small icon buttons, after a separator -->
<Border Width="1"
Background="{DynamicResource Wd.Border}"
Margin="14,4,14,4"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnLaunchTeamsClick"
MouseRightButtonUp="OnLaunchTeamsRightClick"
Padding="10,7"
Margin="0,0,4,0"
ToolTip="Launch Microsoft Teams (right-click to stop)">
<Path Data="M 4,8 L 16,8 L 16,16 L 4,16 Z M 16,11 L 22,8 L 22,16 L 16,13 Z"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Width="18" Height="14"
Stretch="Uniform"/>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnToggleTeamsWindowClick"
Padding="10,7"
ToolTip="Hide / show Teams windows">
<Path Data="M 1,11 C 5,4 17,4 21,11 C 17,18 5,18 1,11 Z M 11,8 A 3,3 0 1 1 11,14 A 3,3 0 1 1 11,8 Z"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="18" Height="14"
Stretch="Uniform"/>
</Button>
</StackPanel>
</Border>
<!--
Participants table — v2 "Studio Terminal" layout.
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
Spec columns (from docs/shapes/2026-05-13-…studio-terminal.md):
1. State LED 24px — 8×8 hard-edged square. Filled cyan
when LIVE; filled coral on ERROR;
filled amber on NO SIGNAL / STARTING;
hollow neutral when OFF.
2. Name + caption * — DisplayName (Inter 13/Medium) plus
source machine + state label below
in JetBrains Mono 11/Tertiary.
3. Audio meter 110px — five vertical hard-edged bars,
each lit when DisplayedAudioLevel
crosses its threshold (0.2, 0.4,
0.6, 0.8, 1.0). No averaging.
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
4. Output name 130px — JetBrains Mono 12 — the NDI source
name TeamsISO broadcasts as.
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan
text; OFF = hollow neutral; ERROR
gets the existing trigger swap.
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
Deliberate deviations from the spec (operator preference, see
4944de5 — "restore live thumbnail preview column"):
• A 106px live thumbnail column sits between State LED and
Name. Replaces the table's previous role as the only place
to see what the operator is broadcasting; the pop-out
preview window is the secondary view.
• A 32px ghost-button cell on the right edge of Name opens
the per-ISO override dialog (framerate / resolution /
aspect / audio). Hidden on hover-out.
Row height 52 (down from 56). Active speaker = full-row
bg.active-speaker tint set by the global DataGridRow style
(avoids the impeccable side-stripe-border ban).
-->
<Border Grid.Row="3"
Margin="20,0,20,12"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Background="{DynamicResource Wd.Surface}">
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
<Grid>
<DataGrid x:Name="ParticipantsGrid"
ItemsSource="{Binding ParticipantsView}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="None"
RowBackground="Transparent"
AlternatingRowBackground="Transparent"
BorderThickness="0"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserResizeRows="False"
SelectionMode="Single"
SelectionUnit="FullRow"
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
RowHeight="52"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}}">
<DataGrid.Columns>
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
Default fill is hollow (transparent with stroke). DataTriggers
swap the fill based on StateLabel: LIVE → cyan filled,
NO SIGNAL / STARTING → amber filled, ERROR → coral filled.
No rounding — broadcast vocabulary uses sharp LEDs. -->
<DataGridTemplateColumn Header="" Width="24" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Rectangle Width="8" Height="8"
HorizontalAlignment="Center"
VerticalAlignment="Center"
StrokeThickness="1.5">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Fill" Value="Transparent"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.BorderStrong}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding StateLabel}" Value="LIVE">
<Setter Property="Fill" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.Accent.Cyan}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateLabel}" Value="ERROR">
<Setter Property="Fill" Value="{DynamicResource Wd.Accent.Coral}"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.Accent.Coral}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateLabel}" Value="NO SIGNAL">
<Setter Property="Fill" Value="{DynamicResource Wd.Status.Warn}"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.Status.Warn}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateLabel}" Value="STARTING">
<Setter Property="Fill" Value="{DynamicResource Wd.Status.Warn}"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.Status.Warn}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 2 — Live preview thumbnail. 96×54 (16:9). WriteableBitmap
fed from the engine's most recent ProcessedFrame at the 1Hz
stats tick. Em-dash placeholder when no pipeline is running. -->
<DataGridTemplateColumn Header="Preview" Width="106" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid VerticalAlignment="Center" HorizontalAlignment="Left" Margin="0,0,8,0">
<Border Width="96" Height="54"
Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="4"
Visibility="{Binding HasThumbnail, Converter={StaticResource BoolToVisInverse}}">
<TextBlock Text="—"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<Border Width="96" Height="54"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="4"
ClipToBounds="True"
Visibility="{Binding HasThumbnail, Converter={StaticResource BoolToVis}}">
<Image Source="{Binding Thumbnail}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="LowQuality"/>
</Border>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 3 — Name + caption. Display name (Inter 13/Medium) above,
source machine + state caption (Mono 11/Tertiary) below. -->
<DataGridTemplateColumn Header="Participant" Width="*" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="13"
FontWeight="Medium"
Foreground="{DynamicResource Wd.Text.Primary}"
TextTrimming="CharacterEllipsis"/>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<TextBlock Text="{Binding SourceMachine}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextTrimming="CharacterEllipsis"
MaxWidth="180"/>
<TextBlock Text=" · "
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Disabled}"/>
<TextBlock Text="{Binding StateLabel}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 3 — Audio meter. 5 vertical bars; each lit when
DisplayedAudioLevel crosses its threshold. -->
<DataGridTemplateColumn Header="Audio" Width="110" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
HorizontalAlignment="Left">
<Rectangle Width="6" Height="6"
Margin="0,0,3,0"
Fill="{DynamicResource Wd.Accent.Cyan}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.2}"
VerticalAlignment="Bottom"/>
<Rectangle Width="6" Height="10"
Margin="0,0,3,0"
Fill="{DynamicResource Wd.Accent.Cyan}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.4}"
VerticalAlignment="Bottom"/>
<Rectangle Width="6" Height="14"
Margin="0,0,3,0"
Fill="{DynamicResource Wd.Accent.Cyan}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.6}"
VerticalAlignment="Bottom"/>
<Rectangle Width="6" Height="18"
Margin="0,0,3,0"
Fill="{DynamicResource Wd.Status.Warn}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.8}"
VerticalAlignment="Bottom"/>
<Rectangle Width="6" Height="22"
Fill="{DynamicResource Wd.Accent.Coral}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.95}"
VerticalAlignment="Bottom"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
will broadcast this participant as. -->
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding OutputName}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP Engine: IsoAssignment record gets optional Override (FrameProcessingSettings?). IsoController hydrates _overrides dict from config.json on startup, uses override at EnableIsoAsync, persists with assignment, exposes GetIsoOverride + SetIsoOverrideAsync. SetIsoOverrideAsync hot-swaps a running pipeline (Disable + 150ms delay + Enable) when the override changes. REST: POST /participants/{id}/override (body: framerate/resolution/aspect/audio enum strings, all optional, missing fall back to globals); DELETE /participants/{id}/override clears. GET /participants now includes per-row effective {framerate, resolution, aspect, audio, isOverride} plus top-level globals block. Web /ui: per-card collapsible override panel with four selects + Apply / Clear. OVR pill + cyan inset edge mark overridden rows. Open-panel state survives WS re-renders. Desktop: per-row gear column in the v2 DataGrid opens IsoOverrideDialog (420x360) with four combos. Clear button removes the override. Thumbnail endpoint switched from WPF JpegBitmapEncoder (NREs from non-UI HttpListener threads) to pure-managed 32bpp BMP encoder. Nearest-neighbor downscale to 192-wide. /participants/{id}/thumbnail.bmp; legacy .jpg URL still works. Known limitation: ParticipantTracker regenerates IDs for display-name-keyed participants across process restarts, orphaning the persisted override. Override works within a session; cross-restart persistence is best-effort until the tracker is taught to use stable keys. Filed as task 43.
2026-05-15 15:31:32 -04:00
<!-- Col 5a — Per-row gear: opens the ISO override editor for this
participant. Narrow (32px) so the table still fits inside a
1280px window after the toggle column. -->
<DataGridTemplateColumn Header="" Width="32" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnIsoOverrideClick"
Padding="6,4"
VerticalAlignment="Center"
HorizontalAlignment="Center"
ToolTip="Override output settings for this participant">
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="14"
Stretch="None"/>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style. -->
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
<DataGridTemplateColumn Header="ISO" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0"
Padding="14,6"
VerticalAlignment="Center">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
<Setter Property="Content" Value="Enable"/>
<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.CyanText}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
polish(mainwindow): empty state, table widths, strings, theme tooltip Walks the v2 polish punch list against MainWindow. - Theme button tooltip is now "Theme (System / Dark / Light)" per the v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)". - Participants table column widths match spec: Output 130px (was 150), ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and 52px row height already matched. The 106px Preview thumbnail column and 32px gear-button column are intentional deviations (live thumbs were restored at 4944de5; per-ISO override gear added at the same time) and are now called out in the column-spec comment so a future reader doesn't try to "fix" them. - Empty-state placeholder finally renders when ParticipantCount == 0: mono sentence "no ndi sources yet — open teams and start a meeting" + a tertiary Refresh discovery button — exactly the copy specified by the shape brief's empty-states section. CountToVisibilityConverter is now declared in MainWindow.Resources (it shipped as a class but was never registered). - OnClosing wraps WindowStateStore.Save in a try/catch so a serialization or filesystem fault on shutdown can never block the window from closing. Save itself already swallows its own IO errors; this is defense-in-depth for anything that escapes. - MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams, Stop Teams) moves to Properties/Strings.resx + a hand-written Properties/Strings.Designer.cs accessor. ResourceManager reads it by basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the EmbeddedResource so the manifest name is predictable regardless of how MSBuild would otherwise compute it. Future-localization seam. OnLaunchTeamsRightClick's confirmation dialog is intentional — it guards a destructive mid-show action — and the code-behind comment now says so; the palette also offers Stop Teams as the keyboard surface, so the right-click affordance isn't the only one. Build clean (0 warnings, 0 errors); 160 tests still pass (56 App + 104 Engine, Category!=ndi&requires!=ndi filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:23 -04:00
<!-- Empty-state placeholder. Renders when no NDI participants
have been discovered yet. Mono sentence + one tertiary
Refresh button — no illustration, no mascot, per the v2
shape brief's empty-states section. -->
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}">
<TextBlock Text="no ndi sources yet — open teams and start a meeting"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding RefreshDiscoveryCommand}"
Content="Refresh discovery (Ctrl+R)"
Padding="14,7"
Margin="0,14,0,0"
HorizontalAlignment="Center"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
ToolTip="Rebuild the NDI finder"/>
</StackPanel>
</Grid>
</Border>
</Grid>
<!-- ═════════════════════ MEETING BAR (conditional) ═════════════════════ -->
<Border Grid.Row="3"
Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,1,0,0"
Padding="20,10,20,10"
Visibility="{Binding IsTeamsInCall, Converter={StaticResource BoolToVis}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Orientation="Horizontal"
VerticalAlignment="Center">
<TextBlock Text="IN CALL"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource Wd.Accent.CyanText}"
VerticalAlignment="Center"
Margin="0,0,10,0"/>
<TextBlock Text="{Binding TeamsMeetingState}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
MaxWidth="500"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<!-- Quick-join field — paste a meeting URL, click Join -->
<StackPanel Grid.Column="1"
Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding IsTeamsInCall, Converter={StaticResource BoolToVisInverse}}">
<!-- Hidden when in call to keep the bar uncluttered -->
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
VerticalAlignment="Center">
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleMuteCommand}"
Content="Mute"
Padding="14,6"
Margin="0,0,6,0"
MinWidth="64"
ToolTip="Toggle microphone mute in Teams"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleCameraCommand}"
Content="Cam"
Padding="14,6"
Margin="0,0,6,0"
MinWidth="64"
ToolTip="Toggle camera in Teams"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding LeaveCallCommand}"
Content="Leave"
Padding="14,6"
MinWidth="64"
Foreground="{DynamicResource Wd.Accent.Coral}"
BorderBrush="{DynamicResource Wd.Accent.Coral}"
ToolTip="Leave the Teams call"/>
</StackPanel>
</Grid>
</Border>
<!-- ═════════════════════ SETTINGS DRAWER (overlay) ═════════════════════
Slides in over the main content from the right when the header gear
is clicked. Backdrop scrim is a semi-transparent overlay over the
whole window; clicking it dismisses the drawer. Esc also dismisses
(handled in code-behind). The drawer carries the same settings
surface as v1 — OUTPUT / NETWORK / DISPLAY tabs with the existing
bindings — so the engine wiring is preserved through the shell
rebuild. -->
<Grid Grid.Row="0"
Grid.RowSpan="4"
x:Name="SettingsDrawerOverlay"
Visibility="Collapsed">
<!-- Scrim — click to dismiss -->
<Border Background="#80000000"
MouseLeftButtonDown="OnSettingsScrimClick"/>
<!-- Drawer panel -->
<Border HorizontalAlignment="Right"
Width="420"
Background="{DynamicResource Wd.Surface}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1,0,0,0">
<DockPanel LastChildFill="True">
<!-- Drawer header -->
<Border DockPanel.Dock="Top"
Padding="20,18,16,16"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Settings"
Style="{StaticResource Wd.Text.Title}"
FontSize="18"/>
<TextBlock Text="Per-session global processing"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,2,0,0"/>
</StackPanel>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Click="OnSettingsToggleClick"
Padding="8,4"
ToolTip="Close (Esc)">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
</Border>
<!-- Drawer footer (Apply) -->
<Border DockPanel.Dock="Bottom"
Padding="20,12,20,16"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right">
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Close"
Click="OnSettingsToggleClick"
Padding="14,6"
Margin="0,0,8,0"/>
<Button Style="{StaticResource Wd.Button.Primary}"
Content="Apply changes"
Command="{Binding Settings.ApplyCommand}"
Padding="14,6"/>
</StackPanel>
</Border>
<!-- Drawer scrollable body — three tabs -->
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Border Padding="20,16,20,16">
<TabControl Style="{StaticResource Wd.TabControl}">
<!-- ─────── OUTPUT ─────── -->
<TabItem Header="OUTPUT" Style="{StaticResource Wd.TabItem}">
<StackPanel>
<TextBlock Text="Target framerate"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,0,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableFramerates}"
SelectedItem="{Binding Settings.Framerate}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Target resolution"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableResolutions}"
SelectedItem="{Binding Settings.Resolution}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Aspect mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAspectModes}"
SelectedItem="{Binding Settings.Aspect}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Audio mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAudioModes}"
SelectedItem="{Binding Settings.Audio}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Reset to defaults"
Command="{Binding Settings.ResetOutputDefaultsCommand}"
HorizontalAlignment="Stretch"
Margin="0,16,0,0"
Padding="0,8"/>
</StackPanel>
</TabItem>
<!-- ─────── NETWORK ─────── -->
<TabItem Header="NETWORK" Style="{StaticResource Wd.TabItem}">
<StackPanel>
<TextBlock Text="Discovery group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,0,0,4"/>
<TextBox Text="{Binding Settings.DiscoveryGroups, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Output group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBox Text="{Binding Settings.OutputGroups, UpdateSourceTrigger=PropertyChanged}"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Apply transcoder topology"
Command="{Binding Settings.ApplyTranscoderTopologyCommand}"
HorizontalAlignment="Stretch"
Margin="0,16,0,0"
Padding="0,9"/>
<TextBlock Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,6,0,0"/>
<TextBlock Text="Output name template"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,16,0,4"/>
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"/>
<TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: TEAMSISO_{guid}."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"/>
</StackPanel>
</TabItem>
<!-- ─────── APP ─────── -->
<TabItem Header="APP" Style="{StaticResource Wd.TabItem}">
<StackPanel>
<CheckBox Content="Hide my self-preview from participants"
IsChecked="{Binding Settings.HideLocalSelf}"/>
<CheckBox Content="Auto-disable ISOs when a participant leaves"
IsChecked="{Binding Settings.AutoDisableOnDeparture}"
Margin="0,8,0,0"/>
<CheckBox Content="Auto-apply last preset on launch"
IsChecked="{Binding Settings.AutoApplyLastPreset}"
Margin="0,8,0,0"/>
<TextBlock Text="Sort participants by"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,14,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableSortModes}"
SelectedItem="{Binding Settings.ParticipantSort}"/>
<CheckBox Content="Minimize to system tray"
IsChecked="{Binding Settings.MinimizeToTray}"
Margin="0,14,0,0"/>
<Border Margin="0,16,0,8"
Height="1"
Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Launch Microsoft Teams on TeamsISO startup"
IsChecked="{Binding Settings.LaunchTeamsOnStartup}"/>
<CheckBox Content="Auto-hide Teams windows when launched"
IsChecked="{Binding Settings.AutoHideTeamsWindows}"
Margin="0,8,0,0"/>
<Border Margin="0,16,0,8"
Height="1"
Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Embed Teams window (experimental)"
IsChecked="{Binding Settings.EmbedTeamsWindow}"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Open embed window"
Click="OnOpenEmbedWindowClick"
HorizontalAlignment="Stretch"
Margin="0,8,0,0"
Padding="0,8"
IsEnabled="{Binding Settings.EmbedTeamsWindow}"/>
<Border Margin="0,16,0,8"
Height="1"
Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Control surface (Stream Deck / Companion / web)"
IsChecked="{Binding Settings.ControlSurfaceEnabled}"/>
<CheckBox Content="LAN-reachable"
IsChecked="{Binding Settings.ControlSurfaceLanReachable}"
Margin="20,4,0,0"/>
<TextBlock Text="{Binding Settings.ControlSurfaceUrl}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.CyanText}"
Margin="20,4,0,0"/>
</StackPanel>
</TabItem>
</TabControl>
</Border>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</Grid>
</Window>