feat(ui): auto-disable ISOs when participants leave the meeting

This commit is contained in:
Zac Gaetano 2026-05-10 09:41:28 -04:00
parent 9cb1cc7b3d
commit 57c2922d1c
3 changed files with 1607 additions and 139 deletions

View file

@ -33,11 +33,29 @@
<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 -->
@ -120,6 +138,22 @@
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}"
@ -277,6 +311,9 @@
<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}"
@ -288,7 +325,67 @@
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2"
<!-- 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}"
@ -299,13 +396,198 @@
<!-- 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="0">
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@ -323,34 +605,104 @@
</Border>
</StackPanel>
<!-- Emergency stop: tears down every running ISO in one click. -->
<Button Grid.Column="1"
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>
<!-- 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>
<TextBlock Grid.Row="1"
Text="Toggle a participant's ISO to spin up an isolated, normalized NDI output."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,6,0,18"/>
<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="2"
<Border Grid.Row="4"
Style="{StaticResource Wd.Card}"
Padding="0">
<Grid>
@ -421,14 +773,80 @@
</StackPanel>
<!-- DataGrid: visible only when Participants.Count > 0. -->
<DataGrid ItemsSource="{Binding Participants}"
<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">
<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}"
@ -466,7 +884,7 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Live" Width="120">
<DataGridTemplateColumn Header="Live" Width="140">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
@ -483,6 +901,22 @@
<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>
@ -500,6 +934,23 @@
</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>
@ -590,127 +1041,259 @@
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,0,22"/>
<!-- Output Format -->
<TextBlock Text="OUTPUT FORMAT" Style="{StaticResource Wd.Text.Caption}"/>
<!--
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}">
<TextBlock Text="Target Framerate"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,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>
<!-- ──────────────── 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="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="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>
<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>
<!-- NDI Network -->
<TextBlock Text="NDI NETWORK"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,28,0,0"/>
<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>
<TextBlock Text="Discovery group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,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"/>
<!-- ──────────────── 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"/>
<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>
<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."/>
<!-- 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."/>
<!-- Display -->
<TextBlock Text="DISPLAY"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,28,0,0"/>
<TextBlock Text="Output name template"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,16,0,4"/>
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
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>
<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."
Margin="0,10,0,0"/>
<!-- ──────────────── 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)"
IsChecked="{Binding Settings.ControlSurfaceEnabled}"
ToolTip="Start a localhost-only HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator) can drive TeamsISO. Bound to 127.0.0.1; not reachable from LAN."/>
<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}"

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Windows;
using TeamsISO.App.Services;
using TeamsISO.Engine.Controller;
@ -20,6 +21,18 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private string _discoveryGroups;
private string _outputGroups;
private bool _hideLocalSelf = true;
private bool _autoDisableOnDeparture = false;
private bool _autoApplyLastPreset;
private bool _recordIsosToDisk;
private string _recordingDirectory = string.Empty;
private bool _controlSurfaceEnabled;
private int _controlSurfacePort = ControlSurfaceServer.DefaultPort;
private bool _oscBridgeEnabled;
private int _oscBridgePort = OscBridge.DefaultPort;
private bool _updateCheckOnLaunch = UpdateChecker.LaunchCheckEnabled;
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
private bool _minimizeToTray;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{
@ -35,8 +48,54 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_discoveryGroups = groups.DiscoveryGroups ?? string.Empty;
_outputGroups = groups.OutputGroups ?? string.Empty;
// Restore persisted UI toggles so the operator's preference survives
// process restarts. UIPreferences keeps a tiny JSON file under
// %LOCALAPPDATA%\TeamsISO\ui-prefs.json — defaults match the original
// in-memory init (HideLocalSelf=true, AutoDisableOnDeparture=false).
var uiPrefs = UIPreferences.Load();
_hideLocalSelf = uiPrefs.HideLocalSelf;
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
_participantSort = uiPrefs.ParticipantSort;
_minimizeToTray = uiPrefs.MinimizeToTray;
// Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens.
try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
catch { /* best-effort — disk read failures shouldn't block UI startup */ }
// Default recording directory: %USERPROFILE%\Videos\TeamsISO\<today's date>.
// Operator can override via the textbox. Date in the path keeps recordings
// from a long-running show day organized without us having to scan + rotate.
_recordingDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
"TeamsISO",
DateTimeOffset.Now.ToString("yyyy-MM-dd"));
_recordIsosToDisk = controller.RecordingEnabled;
if (!string.IsNullOrEmpty(controller.RecordingDirectory))
_recordingDirectory = controller.RecordingDirectory;
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
}
private void ResetOutputDefaults()
{
var confirm = MessageBox.Show(
"Reset framerate, resolution, aspect and audio to TeamsISO defaults?\n\n" +
"This won't touch your NDI group configuration or display toggles.",
"TeamsISO — Reset output defaults",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
var defaults = FrameProcessingSettings.Default;
Framerate = defaults.Framerate;
Resolution = defaults.Resolution;
Aspect = defaults.Aspect;
Audio = defaults.Audio;
_toast?.Show("Output settings reset to defaults — click Apply Changes to commit");
}
public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
@ -59,11 +118,277 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// Hide the user's own self-preview ("(Local)") from the participants list.
/// On by default — operators rarely want to ISO-route their own preview.
/// Read by <see cref="MainViewModel"/> when filtering the list it presents.
/// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>.
/// </summary>
public bool HideLocalSelf { get => _hideLocalSelf; set => SetField(ref _hideLocalSelf, value); }
public bool HideLocalSelf
{
get => _hideLocalSelf;
set
{
if (SetField(ref _hideLocalSelf, value)) PersistUiPrefs();
}
}
/// <summary>
/// When a participant leaves the meeting (their NDI source disappears),
/// automatically tear down their ISO pipeline. Off by default so transient
/// drops don't lose the operator's routing — but useful for clean
/// show-end behavior. Read by MainViewModel when reconciling departures.
/// Persisted to <c>ui-prefs.json</c>.
/// </summary>
public bool AutoDisableOnDeparture
{
get => _autoDisableOnDeparture;
set
{
if (SetField(ref _autoDisableOnDeparture, value)) PersistUiPrefs();
}
}
/// <summary>
/// Available sort modes for the dropdown in DISPLAY settings.
/// </summary>
public IEnumerable<UIPreferences.SortMode> AvailableSortModes => Enum.GetValues<UIPreferences.SortMode>();
/// <summary>
/// How the participants DataGrid is sorted. Persisted across launches via
/// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/>
/// on Application.Current to actually apply the sort to the live view —
/// the settings VM doesn't directly know about the main VM but App holds
/// both and exposes the main window via its DataContext.
/// </summary>
public UIPreferences.SortMode ParticipantSort
{
get => _participantSort;
set
{
if (!SetField(ref _participantSort, value)) return;
PersistUiPrefs();
// Apply to the live view immediately. App.MainWindow.DataContext
// is the MainViewModel; cast and call.
var main = (Application.Current?.MainWindow?.DataContext) as MainViewModel;
main?.SetSortMode(value);
}
}
/// <summary>
/// Minimize-to-tray behavior. When on, minimizing the main window hides
/// it from the taskbar and shows a tray icon (double-click to restore).
/// Right-click menu on the tray icon offers "Show", "Stop all ISOs", "Exit".
/// Useful for long unattended shows where the operator wants TeamsISO
/// running but invisible.
/// </summary>
public bool MinimizeToTray
{
get => _minimizeToTray;
set
{
if (!SetField(ref _minimizeToTray, value)) return;
PersistUiPrefs();
// Reach into the App-owned tray host. App constructs it after the
// main window exists, so the cast is safe at any time the settings
// panel is interactable.
var tray = (Application.Current as App)?.TrayIcon;
if (tray is not null) tray.Enabled = value;
_toast?.Show(value
? "Minimize-to-tray enabled — minimizing now hides the window"
: "Minimize-to-tray disabled");
}
}
/// <summary>
/// Snapshot the current persistable UI state to disk. Called from any
/// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort — disk
/// failures don't surface to the operator (the in-memory state still
/// reflects their click for this session).
/// </summary>
private void PersistUiPrefs() =>
UIPreferences.Save(new UIPreferences.Prefs(
HideLocalSelf: _hideLocalSelf,
AutoDisableOnDeparture: _autoDisableOnDeparture,
ParticipantSort: _participantSort,
MinimizeToTray: _minimizeToTray));
/// <summary>
/// Record each newly-enabled ISO's normalized output to disk under
/// <see cref="RecordingDirectory"/>. Already-running ISOs are not retroactively
/// recorded — the operator should disable + re-enable them. Outputs raw BGRA
/// + manifest.json + convert.cmd; running convert.cmd produces a final
/// H.264 .mkv via FFmpeg.
/// </summary>
public bool RecordIsosToDisk
{
get => _recordIsosToDisk;
set
{
if (SetField(ref _recordIsosToDisk, value))
{
_controller.SetRecording(value, _recordingDirectory);
_toast?.Show(value
? "Recording on — newly-enabled ISOs will write to disk"
: "Recording off");
}
}
}
/// <summary>
/// Output root for recorder files. Each ISO writes a subdirectory keyed by
/// participant display name. Default: <c>%USERPROFILE%\Videos\TeamsISO\&lt;date&gt;</c>.
/// </summary>
public string RecordingDirectory
{
get => _recordingDirectory;
set
{
if (SetField(ref _recordingDirectory, value) && _recordIsosToDisk)
_controller.SetRecording(true, value);
}
}
/// <summary>
/// REST control surface (localhost:port) — Stream Deck / Companion / OSC bridges
/// can hit it. Off by default; bound to 127.0.0.1 so LAN access requires explicit
/// reconfiguration. Toggling reaches into App's owned ControlSurfaceServer.
/// </summary>
public bool ControlSurfaceEnabled
{
get => _controlSurfaceEnabled;
set
{
if (!SetField(ref _controlSurfaceEnabled, value)) return;
var srv = (Application.Current as App)?.ControlSurface;
if (srv is null) return;
if (value) srv.Start(_controlSurfacePort);
else srv.Stop();
_toast?.Show(value
? $"Control surface listening on http://127.0.0.1:{_controlSurfacePort}/"
: "Control surface stopped");
}
}
/// <summary>
/// Port the control surface binds to. Editable while the surface is off; while on,
/// changing the port stops + restarts the listener on the new port.
/// </summary>
public int ControlSurfacePort
{
get => _controlSurfacePort;
set
{
if (!SetField(ref _controlSurfacePort, value)) return;
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(value); // Start is idempotent + handles port change
_toast?.Show($"Control surface restarted on http://127.0.0.1:{value}/");
}
}
/// <summary>
/// OSC bridge over UDP — same command surface as the REST endpoints,
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
/// bound to 127.0.0.1 only.
/// </summary>
public bool OscBridgeEnabled
{
get => _oscBridgeEnabled;
set
{
if (!SetField(ref _oscBridgeEnabled, value)) return;
var bridge = (Application.Current as App)?.OscBridge;
if (bridge is null) return;
if (value) bridge.Start(_oscBridgePort);
else bridge.Stop();
_toast?.Show(value
? $"OSC bridge listening on udp://127.0.0.1:{_oscBridgePort}/"
: "OSC bridge stopped");
}
}
/// <summary>OSC bridge UDP port. Default 9000 (TouchOSC's default).</summary>
public int OscBridgePort
{
get => _oscBridgePort;
set
{
if (!SetField(ref _oscBridgePort, value)) return;
if (!_oscBridgeEnabled) return;
var bridge = (Application.Current as App)?.OscBridge;
bridge?.Start(value);
_toast?.Show($"OSC bridge restarted on udp://127.0.0.1:{value}/");
}
}
/// <summary>
/// Output-name template applied when the operator enables an ISO without
/// a per-participant CustomName. Default <c>"TEAMSISO_{guid}"</c> matches
/// the engine's hard-coded behavior; switch to <c>"TEAMSISO_{name}"</c>
/// for human-readable NDI source names. See <see cref="OutputNameTemplate"/>
/// for the supported tokens.
/// </summary>
public string OutputNameTemplate
{
get => _outputNameTemplate;
set
{
if (SetField(ref _outputNameTemplate, value))
{
TeamsISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty);
}
}
}
/// <summary>
/// Background update check on launch. Throttled to once per 24h via a
/// timestamp file. When a newer release is found, surfaces a non-modal
/// banner with a "Get update" button. Off-by-default would be friendlier
/// for paranoid setups; on-by-default is friendlier for adoption.
/// </summary>
public bool UpdateCheckOnLaunch
{
get => _updateCheckOnLaunch;
set
{
if (SetField(ref _updateCheckOnLaunch, value))
{
UpdateChecker.LaunchCheckEnabled = value;
_toast?.Show(value
? "Update checks enabled — runs once per 24h on launch"
: "Update checks disabled");
}
}
}
/// <summary>
/// On launch, automatically re-apply the most recently applied operator preset.
/// Closes the loop on the recurring-show workflow: the operator clicks Apply
/// once, and from that point on TeamsISO restores the same routing on every
/// subsequent launch as soon as the matching participants come online.
/// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via
/// <see cref="OperatorPresetStore"/>.
/// </summary>
public bool AutoApplyLastPreset
{
get => _autoApplyLastPreset;
set
{
if (SetField(ref _autoApplyLastPreset, value))
{
try { OperatorPresetStore.SetAutoApplyOnStartup(value); }
catch { /* best-effort */ }
}
}
}
public AsyncRelayCommand ApplyCommand { get; }
/// <summary>
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
/// the operator's transcoder topology is a per-machine setting that survives
/// preferences resets) and doesn't touch Display toggles. Confirms first.
/// </summary>
public RelayCommand ResetOutputDefaultsCommand { get; }
/// <summary>
/// One-click "set up the transcoder topology" — writes ndi-config.v1.json so all
/// local senders broadcast on a private group ("teamsiso-input") while local

View file

@ -1,7 +1,10 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Windows.Data;
using System.Windows.Threading;
using TeamsISO.App.Services;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
@ -22,10 +25,88 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…";
// Auto-apply-last-preset bookkeeping. Set on InitializeAsync from disk;
// cleared once we successfully apply (so we don't re-apply when the
// participant list later mutates). The grace deadline gives Teams enough
// time to publish all initial sources after engine start before we attempt
// the apply — applying before everyone's visible would partially-restore
// the routing and silently drop assignments for late-appearing participants.
private string? _pendingPresetName;
private DateTimeOffset _pendingPresetDeadline;
private bool _pendingPresetApplied;
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
/// <summary>
/// Filter-backed view over <see cref="Participants"/>. The DataGrid binds
/// to this rather than the raw collection so the operator's filter text
/// hides non-matching rows without mutating the underlying observable
/// (which would break IsoController's identity tracking).
/// </summary>
public ICollectionView ParticipantsView { get; }
private string _participantFilter = string.Empty;
/// <summary>
/// Apply the operator's saved sort preference to <see cref="ParticipantsView"/>.
/// JoinOrder = no SortDescriptions (whatever order participants are added in);
/// Alphabetical = ascending by DisplayName; OnlineFirst = IsOnline desc then
/// DisplayName asc. Called on construction and from <see cref="SetSortMode"/>.
/// </summary>
private void ApplySortFromPrefs()
{
var prefs = Services.UIPreferences.Load();
SetSortMode(prefs.ParticipantSort);
}
/// <summary>
/// Re-applies the sort descriptions on the ParticipantsView. Called from the
/// settings panel when the operator picks a different sort mode.
/// </summary>
public void SetSortMode(Services.UIPreferences.SortMode mode)
{
ParticipantsView.SortDescriptions.Clear();
switch (mode)
{
case Services.UIPreferences.SortMode.Alphabetical:
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
case Services.UIPreferences.SortMode.OnlineFirst:
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.IsOnline), ListSortDirection.Descending));
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
// JoinOrder: leave SortDescriptions empty.
}
}
/// <summary>
/// Live filter substring. Empty = show everyone. Matched case-insensitively
/// against display name. Setter refreshes the view immediately so the
/// DataGrid reflows as the operator types.
/// </summary>
public string ParticipantFilter
{
get => _participantFilter;
set
{
if (SetField(ref _participantFilter, value))
ParticipantsView.Refresh();
}
}
public GlobalSettingsViewModel Settings { get; }
public AlertBannerViewModel AlertBanner { get; } = new();
public ToastViewModel Toast { get; }
public UpdateBannerViewModel UpdateBanner { get; } = new();
/// <summary>
/// Engine-side controller. Exposed so the PresetsDialog (a Window, not a VM)
/// can re-issue EnableIsoAsync / DisableIsoAsync when applying a preset
/// without us having to plumb a per-action command through the participant
/// view-models from the dialog's XAML.
/// </summary>
internal IIsoController Controller => _controller;
/// <summary>
/// Emergency-stop: disable every running ISO. Bound to a small "Stop all" affordance
@ -34,12 +115,117 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// </summary>
public AsyncRelayCommand StopAllIsosCommand { get; }
/// <summary>
/// Bulk-enable: turn on ISOs for every online participant whose pipeline isn't
/// already running. Useful for "everyone joined, hit one button, every route goes
/// live." Skips offline rows (no source) and rows already enabled.
/// </summary>
public AsyncRelayCommand EnableAllOnlineCommand { get; }
/// <summary>
/// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill
/// next to the participants header — useful right after Apply Transcoder Topology
/// or when Teams restarts mid-session and stale TTLs are masking new sources.
/// </summary>
public RelayCommand RefreshDiscoveryCommand { get; }
// ════════════════════════════════════════════════════════════════════════
// Phase E.3 — In-call controls. Each command drives a UIAutomation lookup
// against Teams' window tree and reports a toast on outcome. Best-effort:
// a control-not-found result toasts a hint rather than throwing, since
// Teams isn't always in a call (the buttons only appear in-call).
// ════════════════════════════════════════════════════════════════════════
public RelayCommand ToggleMuteCommand { get; }
public RelayCommand ToggleCameraCommand { get; }
public RelayCommand LeaveCallCommand { get; }
public RelayCommand OpenShareTrayCommand { get; }
/// <summary>
/// Drop a timestamped marker into every active recording. Bound to a button
/// in the IN-CALL bar; eventually wireable to a global hotkey. The marker
/// label is auto-generated as "Marker @ HH:mm:ss" — operators who want
/// custom labels can edit manifest.json after the fact.
/// </summary>
public RelayCommand DropRecordingMarkerCommand { get; }
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get; }
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; }
/// <summary>
/// Roll-recording: disable + re-enable every currently-recording pipeline,
/// starting a fresh recording chunk in a new subdirectory. Operator-friendly
/// chaptering between show segments without losing already-recorded footage
/// (the previous chunk is finalized on disable, the next chunk starts clean).
/// </summary>
public AsyncRelayCommand RollRecordingCommand { get; }
public string StatusText
{
get => _statusText;
set => SetField(ref _statusText, value);
}
/// <summary>
/// Recording status badge — true when at least one ISO is being recorded.
/// Polled at the existing 1Hz stats tick rather than via a dedicated change
/// event, since recording state shifts on enable/disable transitions and
/// the stats poll already reads each pipeline's state.
/// </summary>
public bool IsRecording
{
get => _isRecording;
private set => SetField(ref _isRecording, value);
}
private bool _isRecording;
/// <summary>Number of pipelines currently writing to the recorder.</summary>
public int ActiveRecordingCount
{
get => _activeRecordingCount;
private set => SetField(ref _activeRecordingCount, value);
}
private int _activeRecordingCount;
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
public bool IsControlSurfaceRunning
{
get => _isControlSurfaceRunning;
private set => SetField(ref _isControlSurfaceRunning, value);
}
private bool _isControlSurfaceRunning;
/// <summary>Human-readable string for the control-surface tooltip ("REST :9755 + OSC :9000").</summary>
public string ControlSurfaceText
{
get => _controlSurfaceText;
private set => SetField(ref _controlSurfaceText, value);
}
private string _controlSurfaceText = string.Empty;
/// <summary>
/// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
/// when nothing's running. Useful for operators tracking show length.
/// Resets when all ISOs go offline (next time one comes back, the timer
/// starts from 00:00:00 again).
/// </summary>
public string SessionElapsed
{
get => _sessionElapsed;
private set => SetField(ref _sessionElapsed, value);
}
private string _sessionElapsed = string.Empty;
public bool IsSessionActive
{
get => _isSessionActive;
private set => SetField(ref _isSessionActive, value);
}
private bool _isSessionActive;
private DateTimeOffset? _sessionStartedAt;
public MainViewModel(IIsoController controller, Dispatcher dispatcher)
{
_controller = controller;
@ -47,6 +233,19 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Toast = new ToastViewModel(dispatcher);
Settings = new GlobalSettingsViewModel(controller, Toast);
// Set up the filter-aware view AFTER Participants is non-null. The
// CollectionView binds to the live collection; Filter callback runs
// each time Refresh() is called or the collection mutates.
ParticipantsView = CollectionViewSource.GetDefaultView(Participants);
ParticipantsView.Filter = obj =>
{
if (string.IsNullOrEmpty(_participantFilter)) return true;
return obj is ParticipantViewModel p &&
p.DisplayName.Contains(_participantFilter, StringComparison.OrdinalIgnoreCase);
};
// Apply the operator's saved sort preference, if any.
ApplySortFromPrefs();
_participantsSub = controller.Participants
.ObserveOn(new SynchronizationContextScheduler(
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
@ -71,6 +270,82 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_statsTimer.Start();
StopAllIsosCommand = new AsyncRelayCommand(StopAllIsosAsync, () => Participants.Any(p => p.IsEnabled));
EnableAllOnlineCommand = new AsyncRelayCommand(EnableAllOnlineAsync,
() => Participants.Any(p => p.IsOnline && !p.IsEnabled));
RefreshDiscoveryCommand = new RelayCommand(() =>
{
_controller.RefreshDiscovery();
Toast.Show("Refreshing NDI discovery…");
});
DropRecordingMarkerCommand = new RelayCommand(() =>
{
var label = "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
Toast.Show($"Marker dropped: {label}");
});
ShowHelpCommand = new RelayCommand(() =>
{
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't
// ship a navigation service and a HelpWindow is purely a UI concern.
// Owner is set so the dialog centers and inherits z-order.
var help = new HelpWindow { Owner = System.Windows.Application.Current?.MainWindow };
help.ShowDialog();
});
ShowNotesCommand = new RelayCommand(() =>
{
var notes = new NotesWindow { Owner = System.Windows.Application.Current?.MainWindow };
notes.Show(); // non-modal so operators can stamp + read alongside the show
});
RollRecordingCommand = new AsyncRelayCommand(RollRecordingAsync,
() => _controller.RecordingEnabled && Participants.Any(p => p.IsEnabled));
ToggleMuteCommand = MakeTeamsCommand(
label: "Mute",
invoke: TeamsControlBridge.ToggleMute,
successMessage: "Toggled mute");
ToggleCameraCommand = MakeTeamsCommand(
label: "Camera",
invoke: TeamsControlBridge.ToggleCamera,
successMessage: "Toggled camera");
LeaveCallCommand = MakeTeamsCommand(
label: "Leave",
invoke: TeamsControlBridge.LeaveCall,
successMessage: "Left the call");
OpenShareTrayCommand = MakeTeamsCommand(
label: "Share",
invoke: TeamsControlBridge.OpenShareTray,
successMessage: "Opened share tray");
}
/// <summary>
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand that
/// translates the result to a user-visible toast. Centralizes the toast wording
/// so the four control commands stay consistent.
/// </summary>
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
{
return new RelayCommand(() =>
{
switch (invoke())
{
case TeamsControlBridge.InvokeResult.Invoked:
Toast.Show(successMessage);
break;
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
Toast.Warn("Teams isn't running.");
break;
case TeamsControlBridge.InvokeResult.ControlNotFound:
Toast.Warn($"{label} control not found — are you in a call?");
break;
case TeamsControlBridge.InvokeResult.InvokeFailed:
Toast.Warn($"{label} button found but disabled.");
break;
}
});
}
/// <summary>
@ -79,16 +354,110 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// in parallel and trip channel-completion races; for ~10 participants this is
/// still sub-second total. Failures are swallowed (best-effort emergency stop).
/// </summary>
/// <summary>
/// Roll every active recording into a new chunk: disable + re-enable every
/// pipeline that's currently running. The recorder finalizes its
/// manifest.json on disable and a fresh subdirectory is created on the
/// next enable (RawBgraRecorderSink uses the participant display name +
/// the timestamp template so consecutive rolls don't collide on disk).
///
/// Per-participant best-effort: one bad pipeline doesn't abort the rest.
/// </summary>
private async Task RollRecordingAsync()
{
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No active ISOs to roll");
return;
}
var rolled = 0;
foreach (var p in enabled)
{
try
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await Task.Delay(150);
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
rolled++;
}
catch
{
// Per-pipeline best-effort
}
}
Toast.Show($"Rolled {rolled} recording(s) into a new chunk");
}
/// <summary>
/// Enable ISOs for every online + non-enabled participant in parallel-ish
/// (sequential await, but each individual EnableIsoAsync is fast). Tolerates
/// per-participant failures so one bad source doesn't abort the rest.
/// </summary>
private async Task EnableAllOnlineAsync()
{
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
var enabled = 0;
foreach (var p in candidates)
{
try
{
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
p.IsEnabled = true;
enabled++;
}
catch
{
// Per-participant best-effort — one bad source shouldn't abort the bulk operation.
}
}
Toast.Show(enabled == 0
? "No participants to enable"
: $"Enabled {enabled} ISO(s)");
}
private async Task StopAllIsosAsync()
{
// Snapshot first so the collection doesn't mutate while we iterate.
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No ISOs to stop");
return;
}
// Confirm before tearing down — this button is an "emergency stop" but
// mis-clicks during a show are easy. The dialog cost is negligible
// (one Enter press) and the regret cost is huge (yanking 5 ISOs mid-
// broadcast). Default selection is No so accidental hits cancel.
var confirm = System.Windows.MessageBox.Show(
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
"TeamsISO — Stop all ISOs",
System.Windows.MessageBoxButton.YesNo,
System.Windows.MessageBoxImage.Warning,
System.Windows.MessageBoxResult.No);
if (confirm != System.Windows.MessageBoxResult.Yes) return;
foreach (var p in enabled)
{
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
catch { /* defensive */ }
p.IsEnabled = false;
}
Toast.Show($"Stopped {enabled.Length} ISO(s)");
}
private void OnStatsTick(object? sender, EventArgs e)
@ -99,6 +468,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{
var stats = _controller.GetStats(vm.Id);
vm.UpdateStats(stats);
// Refresh preview thumbnail from the engine's most recent
// processed frame. Returns null if no pipeline is running for
// this participant; UpdateThumbnail short-circuits in that
// case, leaving the previous frame in place rather than
// visibly blanking when the pipeline restarts.
vm.UpdateThumbnail(_controller.GetLatestProcessedFrame(vm.Id));
}
catch
{
@ -106,6 +481,77 @@ public sealed class MainViewModel : ObservableObject, IDisposable
// tear down the timer or surface an error to the user.
}
}
// Update footer badges. Recording count is "ISOs that have a recorder
// attached" — _controller.RecordingEnabled tells us the global toggle,
// but the actual recorder count = number of running pipelines while
// that toggle was on (transient enables can mean fewer recorders than
// running pipelines). Approximate by ANDing global toggle + running
// ISO count; close enough for an at-a-glance footer.
var totalParticipants = Participants.Count;
var enabledCount = Participants.Count(p => p.IsEnabled);
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
IsRecording = ActiveRecordingCount > 0;
// Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model:
// "the show started when the first feed went live."
if (enabledCount > 0)
{
_sessionStartedAt ??= DateTimeOffset.UtcNow;
var elapsed = DateTimeOffset.UtcNow - _sessionStartedAt.Value;
SessionElapsed = elapsed.TotalHours >= 1
? $"{(int)elapsed.TotalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
: $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}";
IsSessionActive = true;
}
else if (_sessionStartedAt is not null)
{
_sessionStartedAt = null;
SessionElapsed = string.Empty;
IsSessionActive = false;
}
// Dynamic status text — replaces the static "Engine running at X fps"
// once ISOs are live. The framerate target is still implicit (the user
// set it in OUTPUT settings; surfacing it constantly steals footer
// real estate from more-actionable info).
if (totalParticipants == 0)
{
StatusText = "Discovering NDI sources…";
}
else if (enabledCount == 0)
{
StatusText = totalParticipants == 1
? "1 participant visible"
: $"{totalParticipants} participants visible";
}
else if (ActiveRecordingCount > 0 && ActiveRecordingCount != enabledCount)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · {ActiveRecordingCount} recording";
}
else if (ActiveRecordingCount > 0)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · all recording";
}
else
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
}
// Control-surface state — peek at App's owned services.
var app = System.Windows.Application.Current as App;
var rest = app?.ControlSurface?.IsRunning ?? false;
var osc = app?.OscBridge?.IsRunning ?? false;
IsControlSurfaceRunning = rest || osc;
ControlSurfaceText = (rest, osc) switch
{
(true, true) => $"REST :{app!.ControlSurface!.Port} + OSC :{app.OscBridge!.Port}",
(true, false) => $"REST :{app!.ControlSurface!.Port}",
(false, true) => $"OSC :{app!.OscBridge!.Port}",
_ => string.Empty,
};
}
public async Task InitializeAsync(CancellationToken cancellationToken)
@ -113,12 +559,44 @@ public sealed class MainViewModel : ObservableObject, IDisposable
StatusText = "Discovering NDI sources…";
await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
// Auto-apply last preset bookkeeping. We don't apply here — participants
// haven't been discovered yet — instead we record the intent and let
// OnParticipantsChanged trigger the apply once the meeting has populated.
try
{
var pref = Services.OperatorPresetStore.GetStartupPreference();
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
{
_pendingPresetName = pref.LastAppliedName;
// 30s grace window is generous: Teams typically advertises all
// existing participants within 510s of NDI discovery starting.
// After this deadline we apply with whoever is visible.
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
}
}
catch { /* preset read failures shouldn't block engine startup */ }
}
/// <summary>
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
/// that the user-toggled auto-apply path uses, so a single trigger flow
/// covers both. Wins over the persisted preference (operator's CLI intent
/// is more recent than what's on disk).
/// </summary>
public void RequestApplyPresetOnStartup(string presetName)
{
_pendingPresetName = presetName;
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
_pendingPresetApplied = false;
}
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
{
var seenIds = new HashSet<Guid>();
var hideLocal = Settings.HideLocalSelf;
var autoDisable = Settings.AutoDisableOnDeparture;
foreach (var p in incoming)
{
// The new Teams client emits a "(Local)" pseudo-participant for the user's
@ -129,7 +607,35 @@ public sealed class MainViewModel : ObservableObject, IDisposable
seenIds.Add(p.Id);
if (_byId.TryGetValue(p.Id, out var vm))
{
var wasOnline = vm.IsOnline;
vm.Update(p);
// Departure: source went from non-null to null. Always toast so the
// operator notices, even when AutoDisableOnDeparture is off — the
// ISO might still be "running" but emitting a slate frame, which
// looks fine in TeamsISO's UI but is broken downstream.
if (wasOnline && !vm.IsOnline && vm.IsEnabled)
{
if (autoDisable)
{
var captured = vm;
_ = Task.Run(async () =>
{
try { await _controller.DisableIsoAsync(captured.Id, CancellationToken.None); }
catch { /* defensive */ }
await _dispatcher.InvokeAsync(() =>
{
captured.IsEnabled = false;
Toast.Show($"Auto-disabled ISO: {captured.DisplayName} left the meeting");
});
});
}
else
{
// ISO stays running on a slate frame; warn the operator so
// they can decide whether to disable manually.
Toast.Warn($"{vm.DisplayName} disconnected — ISO still running on slate");
}
}
}
else
{
@ -149,6 +655,60 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Participants.RemoveAt(i);
}
}
// Auto-apply-last-preset, second half: once participants populate, kick the
// apply. We fire it under either of two conditions: (a) every display name
// referenced by the preset is present (best case — the meeting is fully
// populated, no skipped assignments), or (b) the grace deadline has passed
// (give up waiting and apply with whoever's online).
if (_pendingPresetName is not null && !_pendingPresetApplied)
{
TryAutoApplyPendingPreset();
}
}
/// <summary>
/// Attempts to apply <see cref="_pendingPresetName"/> if either every preset
/// assignment matches a live participant, or the grace deadline has passed.
/// Idempotent — repeat calls without state change are no-ops; once we fire we
/// flag <c>_pendingPresetApplied</c> so subsequent participant churn doesn't
/// trigger a second apply. Failures (missing preset on disk, preset that no
/// longer matches anyone) are swallowed: the operator can always re-apply
/// manually via the dialog. Delegates to <see cref="PresetApplier.ApplyAsync"/>
/// for the actual reconciliation so the dialog, REST surface, and this auto-
/// apply path all share a single implementation.
/// </summary>
private void TryAutoApplyPendingPreset()
{
Services.OperatorPresetStore.Preset? preset;
try { preset = Services.OperatorPresetStore.Find(_pendingPresetName!); }
catch { preset = null; }
if (preset is null)
{
_pendingPresetApplied = true; // give up; nothing on disk to apply
return;
}
var liveNames = new HashSet<string>(
Participants.Select(p => p.DisplayName),
StringComparer.OrdinalIgnoreCase);
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
return; // wait for the rest of the meeting to populate
_pendingPresetApplied = true;
var captured = preset;
// Snapshot the participants list since we're about to await on a worker
// thread; the live ObservableCollection isn't safe to enumerate from
// outside the dispatcher.
var snapshot = Participants.ToList();
_ = Task.Run(async () =>
{
var result = await PresetApplier.ApplyAsync(
captured, snapshot, _controller, _dispatcher);
await _dispatcher.InvokeAsync(() =>
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
});
}
private static bool IsLocalSelf(Participant p) =>