dragon-iso/src/TeamsISO.App/MainWindow.xaml
Zac Gaetano cc29c503a9
Some checks failed
CI / build-and-test (push) Failing after 28s
Phase E.4 experimental: SetParent-embed Teams window inside TeamsISO
Reparents Teams' main top-level window into a TeamsISO-owned host via Win32 SetParent + window-style stripping. Operator gets Teams visually INSIDE TeamsISO instead of as a separate window — completes the 'Teams runs within this app' direction the user asked for after auto-hide.

Strictly opt-in (DISPLAY tab → 'Embed Teams window (experimental)'). Modern Teams runs WebView2 in its main window; WebView2 is sensitive to parent changes and may render glitches or refuse focus. If so, operator unticks and falls back to auto-hide mode.

Implementation:

- TeamsLauncher.EmbedTeamsInto(hostHwnd, w, h): finds Teams' main window (longest-title heuristic — same as GetActiveWindowTitle), saves original parent + WS_STYLE, SetParents into host, strips WS_CAPTION + WS_THICKFRAME + WS_BORDER + WS_DLGFRAME + WS_POPUP, adds WS_CHILD, MoveWindow to fit.

- TeamsLauncher.RestoreEmbed(): SetParent back to desktop + restore saved window styles. Idempotent — safe to call on shutdown even if nothing was embedded.

- TeamsLauncher.ResizeEmbedded(w, h): MoveWindow to new dimensions; called from host SizeChanged event.

- New TeamsEmbedWindow chromeless host with an EXPERIMENTAL pill in the caption. Loaded → grab HwndSource from EmbedHost Border → call EmbedTeamsInto. SizeChanged → ResizeEmbedded. Closed → RestoreEmbed (in try/finally so a crash can't leave Teams orphaned). Friendly fallback messages if no Teams window exists or HWND grab fails.

- Settings → DISPLAY → checkbox + 'Open embed window' button (gated by the checkbox). Persisted via EmbedTeamsWindow on UIPreferences.
2026-05-10 21:14:42 -04:00

1492 lines
99 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. Click = launch / surface; right-click = stop.
The previous "left-click toggles" behavior ambushed operators
who'd hidden Teams' windows via the eye toggle next door —
they'd hit Launch expecting Teams to come back and instead
get a "Close all Teams windows now?" dialog. Now click is
always idempotent-progressive (running but hidden → show;
not running → launch). Right-click for stop. -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
Click="OnLaunchTeamsClick"
MouseRightButtonUp="OnLaunchTeamsRightClick"
ToolTip="Launch Microsoft Teams (or surface its window). Right-click to stop 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}"/>
<Run Text=" · "/>
<Run Text="{Binding RecordingElapsed, 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,12,0"/>
<!-- Teams meeting state pill — populated from a UIA probe
at the existing 1Hz stats tick. Visible only when Teams
is running. Cyan dot when in a call, gray when Teams is
open but idle. So an operator with Teams auto-hidden can
see at a glance whether they're in a meeting. -->
<Border Style="{StaticResource Wd.Pill}"
VerticalAlignment="Center"
Margin="0,0,16,0"
Padding="10,3"
Visibility="{Binding HasTeamsState, Converter={StaticResource BoolToVis}}"
ToolTip="Microsoft Teams meeting state (probed via UIAutomation at 1Hz)">
<StackPanel Orientation="Horizontal">
<Border Width="6" Height="6"
CornerRadius="3"
Margin="0,0,6,0"
VerticalAlignment="Center">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource Wd.Text.Tertiary}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsTeamsInCall}" Value="True">
<Setter Property="Background" Value="{DynamicResource Wd.Accent.Cyan}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<TextBlock Text="{Binding TeamsMeetingState}"
Style="{StaticResource Wd.Text.Caption}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<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>
<!-- Quick-join: paste a Teams meeting URL, click Join,
Teams launches into the meeting via the shell handler.
Avoids the open-Teams → Calendar → find meeting → click
join dance. Only enabled with non-empty text. -->
<Border Width="1"
Background="{DynamicResource Wd.Border}"
Margin="14,4,12,4"/>
<TextBox Text="{Binding JoinMeetingUrl, UpdateSourceTrigger=PropertyChanged}"
Width="240"
VerticalAlignment="Center"
Padding="10,5"
FontSize="11"
ToolTip="Paste a Teams meeting URL (https://teams.microsoft.com/l/meetup-join/... or msteams:/l/meetup-join/...) and click Join. TeamsISO hands it to Teams to launch + join in one step."/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding JoinMeetingCommand}"
Padding="14,6"
Margin="6,0,0,0"
ToolTip="Hand the pasted meeting URL to Microsoft Teams. With auto-hide on (DISPLAY tab), the Teams window briefly appears then hides automatically.">
<StackPanel Orientation="Horizontal">
<Path Data="M 8,1 L 8,11 M 4,7 L 8,11 L 12,7 M 1,14 L 15,14"
Stroke="{DynamicResource Wd.Accent.Cyan}"
StrokeThickness="1.5"
Fill="Transparent"
Width="16" Height="15"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Join"
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="Save current frame…"
Command="{Binding SaveSnapshotCommand}"
ToolTip="Save the most recent processed frame as a PNG under %USERPROFILE%\Pictures\TeamsISO. Useful for highlight reels, social posts, or attaching to a bug report."/>
<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.
Manual X dismiss for operators running a live show who want
to clear visual clutter without waiting the 3s auto-hide. -->
<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,6,8,6"
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,12,0"
VerticalAlignment="Center"/>
<Button Command="{Binding Toast.DismissCommand}"
Width="20" Height="20"
Background="Transparent"
BorderThickness="0"
Cursor="Hand"
FocusVisualStyle="{x:Null}"
ToolTip="Dismiss this toast">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
CornerRadius="10">
<Path Data="M 0,0 L 8,8 M 8,0 L 0,8"
Stroke="{DynamicResource Wd.Text.Tertiary}"
StrokeThickness="1.2"
Width="8" Height="8"
Stretch="None"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.HoverBg}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</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."/>
<!-- Phase E.1/E.2 "I only see TeamsISO" pair. With both ticked,
the operator launches TeamsISO and never sees the Teams UI —
Teams runs in the background and all interaction routes
through the IN-CALL bar + participants DataGrid. -->
<CheckBox Content="Launch Microsoft Teams on TeamsISO startup"
IsChecked="{Binding Settings.LaunchTeamsOnStartup}"
Margin="0,16,0,0"
ToolTip="When checked, TeamsISO will auto-launch Microsoft Teams in the background each time it starts. Combine with 'Auto-hide Teams windows' for the 'I only see TeamsISO' experience."/>
<CheckBox Content="Auto-hide Teams windows when launched"
IsChecked="{Binding Settings.AutoHideTeamsWindows}"
Margin="0,8,0,0"
ToolTip="When checked, Teams' windows are hidden as soon as they materialize after launch. Use the eye-icon button in the rail to restore them when needed. Drives Teams via the IN-CALL bar (mute / camera / share / leave / marker) and the participants DataGrid for ISO routing."/>
<CheckBox Content="Auto-record when Teams joins a meeting"
IsChecked="{Binding Settings.AutoRecordOnCall}"
Margin="0,8,0,0"
ToolTip="When checked, recording auto-starts the moment Teams transitions into a call (IN CALL pill goes cyan) and auto-stops when the call ends. Removes the manual Record toggle step from unattended-show workflows."/>
<!-- Phase E.4 experimental — SetParent embed. WebView2 in modern
Teams can render weirdly after reparent; if so, untick and
fall back to auto-hide mode. -->
<Border Margin="0,12,0,8"
Height="1"
Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Embed Teams window inside TeamsISO (experimental)"
IsChecked="{Binding Settings.EmbedTeamsWindow}"
Margin="0,4,0,0"
ToolTip="EXPERIMENTAL: Reparent Teams' main window into a TeamsISO-owned host so Teams appears INSIDE our window. WebView2 in modern Teams may render glitches or refuse focus after reparent — if so, untick and use auto-hide mode instead. Click 'Open embed window' below after enabling."/>
<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}"
ToolTip="Open the embed host window. Teams' main window will be reparented into it on load. Close the window to restore Teams to normal top-level state."/>
<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"/>
<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"
TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Content="Open"
Command="{Binding Settings.OpenControlSurfaceCommand}"
Padding="10,2"
Margin="0,0,4,0"
FontSize="11"
ToolTip="Open this URL in your default browser. Useful for previewing how the control panel looks before pointing a phone or second-monitor at it."/>
<Button Grid.Column="2"
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>