dragon-iso/src/TeamsISO.App/MainWindow.xaml
Zac Gaetano 6d9407a61f
Some checks failed
CI / build-and-test (push) Failing after 27s
Add LAN-reachable mode to control surface and OSC bridge
When the new ControlSurfaceLanReachable preference is on, both the REST/WebSocket control surface and the OSC bridge bind to all interfaces (http://+:port/ via HttpListener wildcard, IPAddress.Any for OSC) instead of loopback. The settings VM persists the toggle, restarts both surfaces when flipped, and surfaces a ControlSurfaceUrl computed from the first non-loopback IPv4 + a Copy button so operators can paste the URL onto a control PC.

Use case: a headless host PC runs Teams + TeamsISO; a thin client on the same LAN drives it via /ui or a Stream Deck. Closed-network deployment, no auth — documented as a trusted-LAN-only mode in docs/CONTROL-SURFACE.md, including the one-time 'netsh http add urlacl url=http://+:9755/ user=Everyone' requirement and the firewall rule.
2026-05-10 10:01:32 -04:00

1334 lines
87 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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:vm="clr-namespace:TeamsISO.App.ViewModels"
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}"
WindowStyle="None"
ResizeMode="CanResize"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<!--
Chromeless window chrome — removes the standard Windows title bar so we can
draw our own header that matches the Teams flush look. CaptionHeight=44 makes
the top 44px of the window act as a drag region. ResizeBorderThickness=6
keeps edge-resize working. UseAeroCaptionButtons=False disables the OS-drawn
min/max/close (we draw our own).
-->
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="44"
ResizeBorderThickness="6"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:BoolToVisibilityConverter x:Key="InvertBool"
TrueValue="Collapsed"
FalseValue="Visible"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:InitialsConverter x:Key="Initials"/>
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
</Window.Resources>
<!--
Window-scoped keyboard shortcuts. Bound to view-model commands where
available; F1 routes to a code-behind handler that opens HelpWindow
(the help dialog is a host concern, not a VM concern). Window-scoped
means these only fire when TeamsISO is the foreground window — using
RegisterHotKey for global shortcuts would be a v2 concern (operators
rarely want a Stream Deck and a global hotkey for the same action).
-->
<Window.InputBindings>
<KeyBinding Key="F1" Command="{Binding ShowHelpCommand}"/>
<KeyBinding Key="M" Modifiers="Ctrl" Command="{Binding DropRecordingMarkerCommand}"/>
<KeyBinding Key="S" Modifiers="Ctrl+Shift" Command="{Binding StopAllIsosCommand}"/>
<KeyBinding Key="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/>
</Window.InputBindings>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="72"/> <!-- Left rail -->
<ColumnDefinition Width="*"/> <!-- Main content -->
<ColumnDefinition Width="380"/> <!-- Settings panel -->
</Grid.ColumnDefinitions>
<!-- ════════════════════════════════════════════════════════════════
LEFT RAIL (Teams-style nav)
════════════════════════════════════════════════════════════════ -->
<Border Grid.Column="0"
Background="{DynamicResource Wd.Rail}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,1,0">
<DockPanel LastChildFill="False">
<!-- Wild Dragon mark — real logo from wilddragon.net. Clickable: opens About.
Lives inside the chromeless title bar's drag region, so we opt into
hit-testing so clicks land on the button rather than starting a drag. -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
Width="48" Height="56"
Margin="0,12,0,4"
Click="OnAboutClick"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="About TeamsISO">
<StackPanel>
<Image Source="/Assets/dragon-mark.png"
Width="32" Height="32"
HorizontalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"/>
</StackPanel>
</Button>
<TextBlock DockPanel.Dock="Top"
Text="Wild Dragon"
Style="{StaticResource Wd.Text.Caption}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
HorizontalAlignment="Center"
FontSize="9"
Margin="0,0,0,12"/>
<!-- Divider -->
<Border DockPanel.Dock="Top"
Height="1"
Background="{DynamicResource Wd.Border}"
Margin="14,0,14,12"/>
<!-- Nav: Participants (active) -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
ToolTip="Participants">
<Grid>
<Border Width="48" Height="48"
CornerRadius="8"
Background="{DynamicResource Wd.Accent.CyanMuted}"/>
<Path Data="M 8,17 C 8,13 12,11 14,11 C 16,11 20,13 20,17 M 14,5 C 16,5 17.5,6.5 17.5,8.5 C 17.5,10.5 16,12 14,12 C 12,12 10.5,10.5 10.5,8.5 C 10.5,6.5 12,5 14,5"
Stroke="{DynamicResource Wd.Accent.Cyan}"
StrokeThickness="1.6"
Fill="Transparent"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="28" Height="22"
Stretch="Uniform"/>
</Grid>
</Button>
<!-- Nav: Launch Teams. Subprocess-launches the MS Teams desktop client. -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
Click="OnLaunchTeamsClick"
ToolTip="Launch Microsoft Teams">
<!-- Stylized 'video meeting' camera icon -->
<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.6"
Fill="Transparent"
StrokeLineJoin="Round"
Width="22" Height="20"
Stretch="Uniform"/>
</Button>
<!-- Nav: Hide / Show Teams windows. Phase E.2 of the embedded-Teams roadmap.
Toggles every visible top-level Teams window between SW_HIDE and SW_SHOW
so the operator can keep TeamsISO foregrounded without alt-tabbing. -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
Click="OnToggleTeamsWindowClick"
ToolTip="Hide / show Microsoft Teams windows (keeps Teams running but moves it out of the way)">
<!-- Eye icon (open / closed handled by the click toggling Teams visibility, not the icon) -->
<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="22" Height="22"
Stretch="Uniform"/>
</Button>
<!-- Nav: Settings (placeholder — opens panel on right; not toggled in this build) -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
ToolTip="Settings">
<Path Data="M 14,4 L 14,7 M 14,21 L 14,24 M 4,14 L 7,14 M 21,14 L 24,14 M 7.5,7.5 L 9.5,9.5 M 18.5,18.5 L 20.5,20.5 M 7.5,20.5 L 9.5,18.5 M 18.5,9.5 L 20.5,7.5 M 14,11 C 15.7,11 17,12.3 17,14 C 17,15.7 15.7,17 14,17 C 12.3,17 11,15.7 11,14 C 11,12.3 12.3,11 14,11"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.5"
Fill="Transparent"
Width="22" Height="22"
Stretch="Uniform"/>
</Button>
<!-- Engine status indicator at bottom -->
<Border DockPanel.Dock="Bottom"
Width="40" Height="40"
Margin="0,0,0,16"
HorizontalAlignment="Center"
Background="{DynamicResource Wd.Status.LiveBg}"
CornerRadius="20"
ToolTip="{Binding StatusText}">
<Ellipse Width="10" Height="10"
Fill="{DynamicResource Wd.Status.Live}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</DockPanel>
</Border>
<!-- ════════════════════════════════════════════════════════════════
MAIN CONTENT
════════════════════════════════════════════════════════════════ -->
<DockPanel Grid.Column="1" LastChildFill="True">
<!-- Alert banner -->
<Border DockPanel.Dock="Top"
Background="{DynamicResource Wd.Accent.CoralBg}"
BorderBrush="{DynamicResource Wd.Accent.Coral}"
BorderThickness="0,0,0,1"
Padding="24,12"
Visibility="{Binding AlertBanner.IsVisible, Converter={StaticResource BoolToVis}}">
<DockPanel>
<Button DockPanel.Dock="Right"
Style="{StaticResource Wd.Button.Ghost}"
Content="Dismiss"
Command="{Binding AlertBanner.DismissCommand}"
Margin="12,0,0,0"/>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="8" Height="8"
Fill="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBlock Text="{Binding AlertBanner.Message}"
Style="{StaticResource Wd.Text.Body}"
FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
</DockPanel>
</Border>
<!-- Header strip -->
<Border DockPanel.Dock="Top"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,0,1"
Padding="32,20">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="TeamsISO"
Style="{StaticResource Wd.Text.Title}"
VerticalAlignment="Center"/>
<Border Style="{StaticResource Wd.Pill}"
Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="by Wild Dragon"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Cyan}"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
VerticalAlignment="Center">
<Border Style="{StaticResource Wd.Pill}"
Background="{DynamicResource Wd.Status.LiveBg}"
VerticalAlignment="Center"
Margin="0,0,16,0">
<StackPanel Orientation="Horizontal">
<Ellipse Width="7" Height="7"
Fill="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding StatusText}"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="8,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!--
Custom window control buttons. WindowChrome.IsHitTestVisibleInChrome
tells the chrome that clicks here are real clicks, not drag-region
clicks, so the buttons fire instead of starting a window drag.
-->
<Button x:Name="MinimizeButton"
Style="{StaticResource Wd.Button.Caption}"
Click="OnMinimize"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Minimize">
<Path Data="M 0,5 L 10,5"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
<Button x:Name="MaximizeButton"
Style="{StaticResource Wd.Button.Caption}"
Click="OnMaximizeRestore"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Maximize">
<Path x:Name="MaximizeIcon"
Data="M 0,0 L 10,0 L 10,10 L 0,10 Z"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Fill="Transparent"
Width="10" Height="10"
Stretch="None"/>
</Button>
<Button x:Name="CloseButton"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Close">
<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>
</StackPanel>
</Grid>
</Border>
<!-- Footer -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,1,0,0"
Padding="32,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="WOOGLIN"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1"
Text="{Binding StatusText}"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<!-- Session timer — green dot + elapsed when at least one ISO is live -->
<StackPanel Grid.Column="2"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,16,0"
Visibility="{Binding IsSessionActive, Converter={StaticResource BoolToVis}}"
ToolTip="Elapsed time since the first ISO went live this session.">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Text="{Binding SessionElapsed}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Recording badge — coral dot + count when recording is on -->
<StackPanel Grid.Column="3"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,16,0"
Visibility="{Binding IsRecording, Converter={StaticResource BoolToVis}}"
ToolTip="One or more ISOs are being recorded to disk.">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center">
<Run Text="REC "/>
<Run Text="{Binding ActiveRecordingCount, Mode=OneWay}"/>
</TextBlock>
</StackPanel>
<!-- Control surface badge — cyan dot + REST/OSC string when active -->
<StackPanel Grid.Column="4"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,16,0"
Visibility="{Binding IsControlSurfaceRunning, Converter={StaticResource BoolToVis}}"
ToolTip="External control surface is listening on localhost. See docs/CONTROL-SURFACE.md.">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Text="{Binding ControlSurfaceText}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Column="5"
Text="wilddragon.net · 1.0.0-alpha"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Body -->
<Grid Margin="32,28,32,28">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!--
Update-available banner. Hidden by default; shown by the launch-time
UpdateChecker if a newer release tag exists on Forgejo. "Get update"
opens the releases page; "Dismiss" hides until next launch.
-->
<Border Grid.Row="0"
Style="{StaticResource Wd.Card}"
Padding="14,10"
Margin="0,0,0,14"
Background="{DynamicResource Wd.Accent.CyanMuted}"
BorderBrush="{DynamicResource Wd.Accent.Cyan}"
Visibility="{Binding UpdateBanner.IsVisible, Converter={StaticResource BoolToVis}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Width="8" Height="8"
CornerRadius="4"
Background="{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"
Foreground="{DynamicResource Wd.Text.Primary}"/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Ghost}"
Content="Get update"
Command="{Binding UpdateBanner.OpenReleasePageCommand}"
Padding="14,4"
Margin="0,0,8,0"
ToolTip="Open the Forgejo releases page in your browser to download the new MSI."/>
<Button Grid.Column="3"
Style="{StaticResource Wd.Button.Ghost}"
Content="Dismiss"
Command="{Binding UpdateBanner.DismissCommand}"
Padding="14,4"
ToolTip="Hide this banner. It'll reappear on next launch if the update is still available."/>
</Grid>
</Border>
<!--
Phase E.3 in-call control bar. Drives Microsoft Teams' UI via
UIAutomation so the operator can mute, toggle camera, share, or
leave without alt-tabbing to Teams (especially useful when the
Teams windows are hidden via the rail toggle). Buttons toast the
result; if Teams isn't in a call, the underlying buttons aren't
in the automation tree so the toast says so.
-->
<Border Grid.Row="1"
Style="{StaticResource Wd.Card}"
Padding="14,10"
Margin="0,0,0,18">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<TextBlock Text="IN-CALL"
Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center"
Margin="0,0,16,0"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleMuteCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Toggle Microsoft Teams microphone mute">
<StackPanel Orientation="Horizontal">
<Path Data="M 5,2 L 9,2 L 9,8 A 2,2 0 0 1 5,8 Z M 3,8 A 4,4 0 0 0 11,8 M 7,12 L 7,15 M 5,15 L 9,15"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="16"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Mute"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleCameraCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Toggle Microsoft Teams camera">
<StackPanel Orientation="Horizontal">
<Path Data="M 1,3 L 11,3 L 11,11 L 1,11 Z M 11,5 L 15,3 L 15,11 L 11,9 Z"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="16" Height="14"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Camera"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding OpenShareTrayCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open the Microsoft Teams share tray">
<StackPanel Orientation="Horizontal">
<Path Data="M 8,1 L 8,10 M 4,5 L 8,1 L 12,5 M 1,12 L 1,15 L 15,15 L 15,12"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="16" Height="16"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Share"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding DropRecordingMarkerCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Drop a timestamped marker into every active recording. Useful for chaptering in post — 'guest answer starts here', 'highlight clip', etc. Markers land in each recording's manifest.json.">
<StackPanel Orientation="Horizontal">
<Path Data="M 4,1 L 4,15 M 4,1 L 13,1 L 11,4 L 13,7 L 4,7"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="16"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Marker"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ShowNotesCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open the show-notes viewer for today. Notes can be appended via the REST or OSC notes endpoint or by typing in this dialog's editor link.">
<StackPanel Orientation="Horizontal">
<Path Data="M 2,1 L 12,1 L 12,14 L 2,14 Z M 4,4 L 10,4 M 4,7 L 10,7 M 4,10 L 8,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Fill="Transparent"
Width="14" Height="15"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Notes"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding LeaveCallCommand}"
Padding="14,6"
ToolTip="Leave the Microsoft Teams call">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Leave"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<!-- Section header -->
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Participants"
Style="{StaticResource Wd.Text.Heading}"
FontSize="18"/>
<Border Style="{StaticResource Wd.Pill}"
Margin="12,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding Participants.Count}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"/>
</Border>
</StackPanel>
<!-- Header actions: Refresh discovery, Presets dialog, emergency stop. -->
<StackPanel Grid.Column="1"
Orientation="Horizontal"
VerticalAlignment="Center">
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding EnableAllOnlineCommand}"
VerticalAlignment="Center"
Margin="0,0,8,0"
ToolTip="Enable ISOs for every online participant who isn't already routing. Useful at show-start when everyone has just joined.">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Enable all"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding RefreshDiscoveryCommand}"
VerticalAlignment="Center"
Margin="0,0,8,0"
ToolTip="Force NDI discovery to rebuild — useful right after applying a new transcoder topology or after Teams restarts.">
<StackPanel Orientation="Horizontal">
<Path Data="M 5,1 A 4,4 0 1 1 1.5,5 M 5,1 L 5,3 L 7,3"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Refresh"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnPresetsClick"
VerticalAlignment="Center"
Margin="0,0,8,0"
ToolTip="Save or load named ISO assignment snapshots for recurring shows">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Presets"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding StopAllIsosCommand}"
VerticalAlignment="Center"
ToolTip="Disable every running ISO immediately">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Stop all ISOs"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
<Grid Grid.Row="3" Margin="0,6,0,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Toggle a participant's ISO to spin up an isolated, normalized NDI output."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"/>
<!-- Live filter input — case-insensitive substring match on display name -->
<TextBox Grid.Column="1"
Text="{Binding ParticipantFilter, UpdateSourceTrigger=PropertyChanged}"
MinWidth="200"
Padding="10,6"
FontSize="12"
VerticalAlignment="Center"
ToolTip="Filter the participants list as you type. Case-insensitive substring match against display name."/>
</Grid>
<!-- Participants card -->
<Border Grid.Row="4"
Style="{StaticResource Wd.Card}"
Padding="0">
<Grid>
<!-- Empty-state placeholder. Visible when Participants.Count == 0. -->
<StackPanel Visibility="{Binding Participants.Count, Converter={StaticResource CountToVis}, ConverterParameter=empty}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="460"
Margin="32,48">
<Image Source="/Assets/dragon-mark.png"
Width="64" Height="64"
Opacity="0.45"
HorizontalAlignment="Center"
Margin="0,0,0,20"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="Waiting for Teams"
Style="{StaticResource Wd.Text.Heading}"
FontSize="16"
HorizontalAlignment="Center"/>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"
Margin="0,8,0,20"
Text="No Teams participants discovered on the network yet. Once Teams broadcasts NDI sources, this list will populate within a few seconds."/>
<Border Style="{StaticResource Wd.Card}"
Background="{DynamicResource Wd.SurfaceElevated}"
Padding="16,12">
<StackPanel>
<TextBlock Text="QUICK CHECKLIST"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,0,0,8"/>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
FontSize="12"
Margin="0,0,0,4">
<Run Text="•"/>
<Run Text=" Microsoft Teams is running and a meeting is active"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
FontSize="12"
Margin="0,0,0,4">
<Run Text="•"/>
<Run Text=" NDI broadcast is enabled in Teams settings"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
FontSize="12"
Margin="0,0,0,4">
<Run Text="•"/>
<Run Text=" Discovery group in Settings matches Teams' broadcast group"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
FontSize="12">
<Run Text="•"/>
<Run Text=" Windows Firewall isn't blocking NDI multicast on the active network adapter"/>
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
<!-- DataGrid: visible only when Participants.Count > 0. -->
<DataGrid ItemsSource="{Binding ParticipantsView}"
Visibility="{Binding Participants.Count, Converter={StaticResource CountToVis}}"
Margin="6,4,6,4">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="Toggle ISO" Command="{Binding ToggleIsoCommand}"/>
<MenuItem Header="Restart this ISO"
Command="{Binding RestartIsoCommand}"
ToolTip="Disable + re-enable this pipeline only. Useful when a single feed flakes (drops climb, framerate jitters) without affecting other ISOs."/>
<MenuItem Header="Open preview…"
Command="{Binding OpenPreviewCommand}"
ToolTip="Pop out a floating live-preview window. Drag to a second monitor for full-screen monitoring."/>
<MenuItem Header="Record this participant"
IsCheckable="True"
IsChecked="{Binding RecordToDisk}"/>
<Separator/>
<MenuItem Header="Copy NDI source name"
Command="{Binding CopySourceNameCommand}"
ToolTip="{Binding SourceFullName}"/>
</ContextMenu>
</Setter.Value>
</Setter>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<!--
Live preview thumbnail. 160×90 (16:9) WriteableBitmap fed
from the engine's most recent ProcessedFrame at the 1Hz
stats tick. Falls back to the avatar initials when no
pipeline is running (Thumbnail is null).
-->
<DataGridTemplateColumn Header="Preview" Width="120">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid VerticalAlignment="Center" HorizontalAlignment="Center">
<!-- Placeholder shown when no thumbnail yet -->
<Border Width="100" Height="56"
Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="4"
Visibility="{Binding HasThumbnail, Converter={StaticResource InvertBool}}">
<TextBlock Text="—"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- Live thumbnail. Stretch=Uniform preserves aspect; clip via Border for rounded corners -->
<Border Width="100" Height="56"
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>
<DataGridTemplateColumn Header="Display Name" Width="2*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
ToolTip="{Binding SourceFullName}">
<Border Style="{StaticResource Wd.Avatar}">
<TextBlock Text="{Binding DisplayName, Converter={StaticResource Initials}}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Sans}"
FontWeight="SemiBold"
FontSize="11"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding DisplayName}"
Style="{StaticResource Wd.Text.Body}"
FontWeight="Medium"
Margin="14,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Source" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding SourceMachine}"
Style="{StaticResource Wd.Text.Mono}"/>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
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="140">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11">
<Run Text="↓ "/>
<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.Accent.Coral}">
<Run Text="drop "/>
<Run Text="{Binding FramesDropped, Mode=OneWay}"/>
</TextBlock>
<!-- VU bar — width-bound to AudioLevelWidthPercent. The
outer Border is the 100-pixel-wide track; the inner
Border is the level fill. When the engine doesn't
yet feed PeakAudioLevel (current behavior), the bar
stays at 0 width. ToolTip explains the empty state. -->
<Border Width="100" Height="4"
HorizontalAlignment="Left"
Margin="0,4,0,0"
Background="{DynamicResource Wd.SurfaceElevated}"
CornerRadius="2"
ToolTip="Live audio peak. Engine audio capture is a follow-up; the bar will animate once that ships.">
<Border HorizontalAlignment="Left"
Width="{Binding AudioLevelWidthPercent, Mode=OneWay}"
Background="{DynamicResource Wd.Status.Live}"
CornerRadius="2"/>
</Border>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Output Name" Width="2*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBox Text="{Binding CustomName, UpdateSourceTrigger=PropertyChanged}"
Padding="10,7"
Margin="0,0,12,0"
VerticalAlignment="Center"
ToolTip="Override the auto-generated NDI output name (TEAMSISO_xxxxxxxx). Takes effect when ISO is enabled."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!--
Per-participant recording opt-out. When global recording is on,
only participants with this checkbox checked get a recorder when
their ISO starts. Read at EnableIsoAsync time so toggling on a
running pipeline has no effect — operator must disable + re-enable.
-->
<DataGridTemplateColumn Header="Rec" Width="60">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding RecordToDisk}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ToolTip="When on, this participant's ISO is recorded to disk while global recording is enabled. Toggle while ISO is OFF — changes don't apply to a running pipeline."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="ISO" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0"
ToolTip="Toggle this participant's normalized NDI ISO output">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
<Setter Property="Content" Value="Enable"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Secondary}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEnabled}" Value="True">
<Setter Property="Content" Value="● LIVE"/>
<Setter Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Cyan}"/>
</DataTrigger>
<!-- Pipeline failed past retry budget — surface the error state in coral. -->
<DataTrigger Binding="{Binding StateLabel}" Value="ERROR">
<Setter Property="Content" Value="● ERROR"/>
<Setter Property="Background" Value="{DynamicResource Wd.Accent.CoralBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Coral}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Coral}"/>
</DataTrigger>
<!-- No frames received in slate-threshold window — amber warning. -->
<DataTrigger Binding="{Binding StateLabel}" Value="NO SIGNAL">
<Setter Property="Content" Value="● NO SIGNAL"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Status.Warn}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Status.Warn}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsProcessing}" Value="True">
<Setter Property="Content" Value="…"/>
<Setter Property="IsEnabled" Value="False"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border>
<!-- Toast overlay: bottom-center, auto-dismissing transient notification. -->
<Border Grid.Row="2"
VerticalAlignment="Bottom"
HorizontalAlignment="Center"
Margin="0,0,0,16"
Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.BorderStrong}"
BorderThickness="1"
CornerRadius="999"
Padding="14,8"
Visibility="{Binding Toast.IsVisible, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8"
Fill="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Toast.Message}"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="10,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</DockPanel>
<!-- ════════════════════════════════════════════════════════════════
SETTINGS PANEL (right)
════════════════════════════════════════════════════════════════ -->
<Border Grid.Column="2"
Background="{DynamicResource Wd.Surface}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1,0,0,0">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="24,28,24,32">
<TextBlock Text="Settings"
Style="{StaticResource Wd.Text.Heading}"
FontSize="16"
Margin="0,0,0,4"/>
<TextBlock Text="Per-meeting global processing"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,0,22"/>
<!--
Settings panel is grouped into three tabs (Output, Network, Display)
rather than one long scroll. Apply Changes lives outside the tabs so
it commits all three sections at once — the binding paths span groups
anyway (framerate + group strings + display toggles all roundtrip
through the same Settings.ApplyCommand).
-->
<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}"
ToolTip="The framerate every ISO output is normalized to. Click Apply to commit.">
<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}"
ToolTip="Output resolution. Source frames smaller than this are scaled up; larger frames are scaled down.">
<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}"
ToolTip="How to fit non-matching aspect ratios. Pillarbox = bars on the sides; Letterbox = bars top/bottom; Stretch = distort to fill.">
<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}"
ToolTip="Audio routing for ISO outputs. Auto = isolated when available, fall back to mixed; Isolated = participant audio only; Mixed = full meeting mix.">
<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"
ToolTip="Restore framerate, resolution, aspect and audio to TeamsISO defaults. Doesn't touch NDI groups or display toggles."/>
</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}"
ToolTip="Comma-separated NDI group names the engine subscribes to. Empty = Public (default)."/>
<TextBlock Text="Receive sources from this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
<TextBlock Text="Output group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBox Text="{Binding Settings.OutputGroups, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Comma-separated NDI group(s) TeamsISO's normalized ISO outputs broadcast on. Empty = Public."/>
<TextBlock Text="Broadcast TeamsISO outputs on this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
<Border Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Padding="12,10"
Margin="0,12,0,0">
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
Text="Group changes apply on next launch — running pipelines aren't restarted to avoid orphaning live ISOs."/>
</Border>
<!-- One-click transcoder topology setup. Writes the system-wide
NDI config so Teams broadcasts on a private group, then sets
the engine to consume from that group and re-emit on Public. -->
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Apply transcoder topology"
Command="{Binding Settings.ApplyTranscoderTopologyCommand}"
HorizontalAlignment="Stretch"
Margin="0,12,0,0"
Padding="0,9"/>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"
Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying."/>
<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"
ToolTip="Template applied to NDI output names when no per-participant Custom Name is set. Tokens: {name} = display name, {guid} = first-8 of Id, {machine} = PC name, {timestamp} = yyyyMMdd_HHmmss."/>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"
Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: TEAMSISO_{guid}. Use TEAMSISO_{name} for human-readable downstream switcher names."/>
</StackPanel>
</TabItem>
<!-- ──────────────── Display ──────────────── -->
<TabItem Header="DISPLAY" Style="{StaticResource Wd.TabItem}">
<StackPanel>
<CheckBox Content="Hide my self-preview from participants"
IsChecked="{Binding Settings.HideLocalSelf}"
ToolTip="Filters out the '(Local)' source — your own preview that Teams broadcasts on the same machine — so you don't accidentally route it as an ISO."/>
<CheckBox Content="Auto-disable ISOs when a participant leaves"
IsChecked="{Binding Settings.AutoDisableOnDeparture}"
ToolTip="When checked, departing participants automatically have their ISO pipelines torn down. Off by default so transient drops don't lose your routing."
Margin="0,10,0,0"/>
<CheckBox Content="Auto-apply last preset on launch"
IsChecked="{Binding Settings.AutoApplyLastPreset}"
ToolTip="On launch, automatically re-apply the most recently applied operator preset once participants populate. Useful for recurring shows where the same routing should be restored every session."
Margin="0,10,0,0"/>
<TextBlock Text="Sort participants by"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableSortModes}"
SelectedItem="{Binding Settings.ParticipantSort}"
ToolTip="JoinOrder = whatever order Teams emits sources (default). Alphabetical = stable across meetings, good for Stream Deck button assignments. OnlineFirst = online participants float to the top."/>
<CheckBox Content="Minimize to system tray"
IsChecked="{Binding Settings.MinimizeToTray}"
Margin="0,12,0,0"
ToolTip="When checked, minimizing the window hides it and shows a tray icon. Useful for long unattended shows. Double-click the tray icon to restore."/>
<Separator Margin="0,16,0,8"/>
<CheckBox Content="Record ISOs to disk"
IsChecked="{Binding Settings.RecordIsosToDisk}"
ToolTip="When checked, each newly-enabled ISO writes raw BGRA frames + manifest.json + convert.cmd to its own subdirectory. Run convert.cmd to produce H.264 .mkv via FFmpeg. Recording starts on the next ISO enable; already-running ISOs aren't retroactively captured."/>
<TextBlock Text="Output directory"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"
IsEnabled="{Binding Settings.RecordIsosToDisk}"/>
<TextBox Text="{Binding Settings.RecordingDirectory, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding Settings.RecordIsosToDisk}"
ToolTip="Root directory for recordings. Each participant gets a subdirectory under this path. Default: %USERPROFILE%\Videos\TeamsISO\&lt;date&gt;."/>
<TextBlock Text="Recordings live under this path. Each ISO writes raw BGRA + manifest.json. Double-click the convert.cmd inside to produce a final H.264 .mkv."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"/>
<Separator Margin="0,16,0,8"/>
<CheckBox Content="Control surface (Stream Deck / Companion / web)"
IsChecked="{Binding Settings.ControlSurfaceEnabled}"
ToolTip="Start an HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator) or a thin-client browser can drive TeamsISO."/>
<CheckBox Content="LAN-reachable (allow other machines on your network)"
IsChecked="{Binding Settings.ControlSurfaceLanReachable}"
Margin="20,4,0,0"
ToolTip="When checked, the control surface binds to all interfaces (0.0.0.0) instead of 127.0.0.1, so a phone or laptop on your LAN can drive TeamsISO. First time you turn this on, run this in an Administrator PowerShell once: netsh http add urlacl url=http://+:9755/ user=Everyone"/>
<Grid Margin="20,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Settings.ControlSurfaceUrl}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Content="Copy"
Command="{Binding Settings.CopyControlSurfaceUrlCommand}"
Padding="10,2"
FontSize="11"
ToolTip="Copy this URL to the clipboard so you can paste it into a phone browser or controller."/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Port"
Style="{StaticResource Wd.Text.Subtle}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBox Grid.Column="1"
Text="{Binding Settings.ControlSurfacePort, UpdateSourceTrigger=LostFocus}"
ToolTip="Listening port for the REST control surface. Default 9755."/>
</Grid>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"
Text="Endpoints: GET /participants, POST /participants/{id}/iso, /presets/{name}/apply, /presets/refresh-discovery, /presets/stop-all, /teams/mute|camera|leave|share|raise-hand, /recording. WebSocket /ws pushes live state. See docs/CONTROL-SURFACE.md."/>
<CheckBox Content="OSC bridge (UDP)"
IsChecked="{Binding Settings.OscBridgeEnabled}"
Margin="0,12,0,0"
ToolTip="UDP listener that speaks the same command surface as REST. /teamsiso/iso 'Jane' 1, /teamsiso/preset 'Friday Show', /teamsiso/teams/mute, etc. Bound to 127.0.0.1 only."/>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="OSC port"
Style="{StaticResource Wd.Text.Subtle}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBox Grid.Column="1"
Text="{Binding Settings.OscBridgePort, UpdateSourceTrigger=LostFocus}"
ToolTip="UDP port for the OSC bridge. Default 9000 (TouchOSC's default)."/>
</Grid>
<Separator Margin="0,16,0,8"/>
<CheckBox Content="Check for updates on launch"
IsChecked="{Binding Settings.UpdateCheckOnLaunch}"
ToolTip="On startup, ask forge.wilddragon.net whether a newer release exists. Throttled to once per 24h. If newer, a banner appears at the top of the participants area; click 'Get update' to open the releases page in your browser."/>
</StackPanel>
</TabItem>
</TabControl>
<!--
Apply Changes commits OUTPUT + NETWORK fields together. DISPLAY-tab
toggles persist on each click (HideLocalSelf is in-memory only;
AutoDisableOnDeparture / AutoApplyLastPreset write through to disk
from their setters), so they don't strictly need this button — but
keeping it visible from any tab gives the user a single confirm.
-->
<Button Style="{StaticResource Wd.Button.Primary}"
Content="Apply Changes"
Command="{Binding Settings.ApplyCommand}"
HorizontalAlignment="Stretch"
Margin="0,28,0,0"
Padding="0,11"
ToolTip="Persist all settings to %APPDATA%\TeamsISO\config.json. Group changes apply on next launch."/>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</Window>