feat(ui): auto-disable ISOs when participants leave the meeting
This commit is contained in:
parent
9cb1cc7b3d
commit
57c2922d1c
3 changed files with 1607 additions and 139 deletions
|
|
@ -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,9 +605,64 @@
|
|||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Emergency stop: tears down every running ISO in one click. -->
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
<!-- 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">
|
||||
|
|
@ -341,16 +678,31 @@
|
|||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
<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}"
|
||||
Margin="0,6,0,18"/>
|
||||
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,12 +1041,21 @@
|
|||
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}">
|
||||
|
||||
<!-- ──────────────── Output ──────────────── -->
|
||||
<TabItem Header="OUTPUT" Style="{StaticResource Wd.TabItem}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Target Framerate"
|
||||
Style="{StaticResource Wd.Text.Subtle}"
|
||||
Margin="0,10,0,4"/>
|
||||
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.">
|
||||
|
|
@ -645,14 +1105,22 @@
|
|||
</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>
|
||||
|
||||
<!-- ──────────────── Network ──────────────── -->
|
||||
<TabItem Header="NETWORK" Style="{StaticResource Wd.TabItem}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Discovery group"
|
||||
Style="{StaticResource Wd.Text.Subtle}"
|
||||
Margin="0,10,0,4"/>
|
||||
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."
|
||||
|
|
@ -701,16 +1169,131 @@
|
|||
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>
|
||||
|
||||
<!-- ──────────────── 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."
|
||||
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\<date>."/>
|
||||
<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}"
|
||||
|
|
|
|||
|
|
@ -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\<date></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
|
||||
|
|
|
|||
|
|
@ -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 5–10s 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) =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue